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::{load_config, tr};
29use jiff::Zoned;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use sioul_core::config::{Config, config_dir, state_dir};
33use sioul_core::phones;
34use sioul_core::porch::Senders;
35use sioul_core::texts::{self as rules, Claim, Outcome, Part, Request, Text, Verdict};
36use sioul_sync::textseal::TextSeal;
37use std::collections::{BTreeMap, BTreeSet};
38use std::path::{Path, PathBuf};
39use std::sync::Mutex;
40use std::sync::atomic::{AtomicBool, Ordering};
41
42/// Whether this device reads and sends texts: a phone. SIOUL_TEXTS makes a computer's step run too, to look at it.
43fn phone() -> bool {
44    cfg!(target_os = "android") || std::env::var_os("SIOUL_TEXTS").is_some()
45}
46
47/// The demo profile (`SIOUL_DEMO=1`, which tools/demo/run.sh sets in its
48/// sandbox with no network, and nothing else does): its texts are stand-ins
49/// written plain by tools/demo/make-demo.py, its phone a stand-in.
50fn demo() -> bool {
51    std::env::var_os("SIOUL_DEMO").is_some()
52}
53
54/// What seals the lines and the media: the sharing key's, or the demo's stand-in (plain; a line marked).
55enum Seal {
56    Key(TextSeal),
57    Demo,
58}
59
60impl rules::Sealer for Seal {
61    fn seal(&self, plain: &str) -> String {
62        match self {
63            Seal::Key(seal) => seal.seal(plain),
64            Seal::Demo => format!("demo:{plain}"),
65        }
66    }
67
68    fn open(&self, sealed: &str) -> Option<String> {
69        match self {
70            Seal::Key(seal) => seal.open(sealed),
71            Seal::Demo => sealed.strip_prefix("demo:").map(str::to_string),
72        }
73    }
74}
75
76impl Seal {
77    fn seal_bytes(&self, plain: &[u8]) -> Vec<u8> {
78        match self {
79            Seal::Key(seal) => seal.seal_bytes(plain),
80            Seal::Demo => plain.to_vec(),
81        }
82    }
83
84    fn open_bytes(&self, sealed: &[u8]) -> Option<Vec<u8>> {
85        match self {
86            Seal::Key(seal) => seal.open_bytes(sealed),
87            Seal::Demo => Some(sealed.to_vec()),
88        }
89    }
90}
91
92/// The sharing's folder and key: where the media's blobs go and come from.
93type Vault = (PathBuf, [u8; 32]);
94
95/// This device's name in the sharing, the sealer, and the sharing's folder
96/// and key, while the part "texts" is on here (the demo: no folder).
97fn sealed() -> Option<(String, Seal, Option<Vault>)> {
98    if demo() && !phone() {
99        return Some(("demo-desk".into(), Seal::Demo, None));
100    }
101    let (here, vault) = crate::share::vault()?;
102    let (folder, key) = vault?;
103    crate::share::shares(rules::PART).then(|| (here, Seal::Key(TextSeal::new(&key)), Some((folder, key))))
104}
105
106fn root() -> PathBuf {
107    rules::folder()
108}
109
110fn private() -> PathBuf {
111    rules::private_folder()
112}
113
114fn now_ms() -> i64 {
115    jiff::Timestamp::now().as_millisecond()
116}
117
118/// A private file written whole beside, then put in place.
119fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), String> {
120    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
121    if let Some(dir) = path.parent() {
122        std::fs::create_dir_all(dir).map_err(fail)?;
123    }
124    let fresh = path.with_extension(format!("{}.new", std::process::id()));
125    std::fs::write(&fresh, bytes).map_err(fail)?;
126    std::fs::rename(&fresh, path).map_err(|e| {
127        let _ = std::fs::remove_file(&fresh);
128        fail(e)
129    })
130}
131
132// ---------------------------------------------------------------- this device's choices
133
134/// This device's own texts settings (`texts.toml` in the configuration, never
135/// shared): on a computer, the phone that sends; on a phone, the largest
136/// media file it brings to your computers (MB; none: `MEDIA_CAP_MB`).
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138struct Choice {
139    #[serde(default, skip_serializing_if = "String::is_empty")]
140    phone: String,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    media_cap_mb: Option<u64>,
143}
144
145fn choice_path() -> PathBuf {
146    config_dir().join("texts.toml")
147}
148
149fn choice() -> Choice {
150    std::fs::read_to_string(choice_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
151}
152
153/// The sizes offered for the largest media file (MB): the blobs take 64 MB at most.
154const CAPS_MB: [u64; 5] = [1, 5, 10, 25, 50];
155
156/// The largest media file brought to your computers, in MB.
157fn cap_mb() -> u64 {
158    choice().media_cap_mb.unwrap_or(rules::MEDIA_CAP_MB).clamp(1, sioul_sync::blobs::LARGEST >> 20)
159}
160
161// ---------------------------------------------------------------- the phone: the whole history
162
163/// The reader's marks and the import's measures, private to the phone
164/// (`texts/reader.json` in the state folder): the provider's last rows read,
165/// when the import began, what the phone holds and how much has gone.
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(default)]
168struct Reader {
169    /// `READER_VERSION`; 0, the first version's (30 days, before the archive).
170    version: u32,
171    sms: i64,
172    mms: i64,
173    started: i64,
174    /// What the phone holds, as last measured (texts; media bytes), and when.
175    total_texts: u64,
176    total_media: u64,
177    measured: i64,
178    /// What has gone to your computers: texts written, media bytes put in the sharing.
179    done_texts: u64,
180    done_media: u64,
181    /// When the deletions were last looked for.
182    looked: i64,
183    /// The last batch left texts to read: full, or a message's media that did
184    /// not fit. The import goes on only while the phone charges (`import_now`).
185    behind: bool,
186}
187
188/// The reader of the archive. The first version read 30 days: its marks
189/// would pass the history, so its reader starts again from the first row.
190const READER_VERSION: u32 = 2;
191
192impl Reader {
193    fn path() -> PathBuf {
194        private().join(rules::READER)
195    }
196
197    fn load() -> Reader {
198        let fresh = || Reader { version: READER_VERSION, ..Reader::default() };
199        match std::fs::read_to_string(Reader::path()).ok().and_then(|t| serde_json::from_str::<Reader>(&t).ok()) {
200            Some(reader) if reader.version == READER_VERSION => reader,
201            Some(_) => {
202                // The first version's stores, in the state folder then: sealed copies of 30 days, read again whole now.
203                for old in ["log", "send", "outcome", "ask", "answer"] {
204                    let _ = std::fs::remove_dir_all(private().join(old));
205                }
206                eprintln!("sioul: texts: the reader starts again from the first text, for the archive");
207                fresh()
208            }
209            None => fresh(),
210        }
211    }
212
213    fn save(&self) -> Result<(), String> {
214        write_whole(&Reader::path(), serde_json::to_string(self).map_err(|e| e.to_string())?.as_bytes())
215    }
216
217    /// What `texts-ids` measured: how many texts, how many media bytes.
218    fn measure(&mut self, answer: &Value, now: i64) {
219        let count = |name: &str| answer[name].as_array().map_or(0, Vec::len) as u64;
220        self.total_texts = count("sms") + count("mms");
221        self.total_media = answer["media"].as_u64().unwrap_or(0);
222        self.measured = now;
223    }
224}
225
226/// A row as Java read it (Texts.sms, Texts.mms; android/jvm-checks/TextsCheck.java): every column.
227#[derive(Debug, Clone, Default, Deserialize)]
228#[serde(default)]
229struct Raw {
230    kind: String,
231    fields: BTreeMap<String, Value>,
232    addrs: Vec<BTreeMap<String, Value>>,
233    parts: Vec<BTreeMap<String, Value>>,
234}
235
236fn int(fields: &BTreeMap<String, Value>, name: &str) -> i64 {
237    fields.get(name).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))).unwrap_or(0)
238}
239
240fn string(fields: &BTreeMap<String, Value>, name: &str) -> String {
241    fields.get(name).and_then(Value::as_str).unwrap_or_default().to_string()
242}
243
244/// A multimedia message's address types (`addr.type`): who sent it, to whom.
245const MMS_FROM: i64 = 137;
246const MMS_TO: i64 = 151;
247/// A multimedia message still to download (`m_type`: Android has only its notification).
248const MMS_NOTIFICATION: i64 = 130;
249/// What Android writes for "this phone" in a message's addresses.
250const OWN_ADDRESS: &str = "insert-address-token";
251
252/// A part as it travels: its words in the line; its content said, to be put
253/// in the sharing (`bring_media`) when it is not over `cap` bytes.
254fn part_of(columns: &BTreeMap<String, Value>, waiting: bool, cap: u64) -> Part {
255    let ct = string(columns, "ct");
256    let words = ct.starts_with("text/plain") || ct == "application/smil";
257    let size = int(columns, "size");
258    let state = if words {
259        "text"
260    } else if waiting || size < 0 {
261        "not-downloaded"
262    } else if u64::try_from(size).unwrap_or(0) > cap {
263        "too-big"
264    } else {
265        "here"
266    };
267    let name = ["name", "cl", "fn"].iter().map(|n| string(columns, n)).find(|n| !n.is_empty()).unwrap_or_default();
268    // The other columns as they came; the size is Android's measure, not a column.
269    let mut fields = columns.clone();
270    for held in ["seq", "ct", "text", "size"] {
271        fields.remove(held);
272    }
273    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 }
274}
275
276/// A row Java read, as it travels: its people keyed (its conversation's,
277/// else its own addresses), its words, its parts said (their content left for
278/// `bring_media`), every column kept as it came.
279fn text_of(raw: &Raw, threads: &BTreeMap<String, Vec<String>>, region: Option<&phones::Region>, cap: u64) -> Text {
280    let key = |a: &str| phones::key(a, region);
281    let f = &raw.fields;
282    let thread = int(f, "thread_id").to_string();
283    let people: Vec<String> = threads.get(&thread).map(|list| list.iter().map(|a| key(a)).filter(|k| !k.is_empty()).collect()).unwrap_or_default();
284    let sub = i32::try_from(int(f, "sub_id")).ok().filter(|_| f.contains_key("sub_id")).unwrap_or(-1);
285    if raw.kind == "mms" {
286        let outgoing = matches!(int(f, "msg_box"), 2 | 4);
287        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() };
288        let from = addresses(MMS_FROM).into_iter().next().unwrap_or_default();
289        let with = if !people.is_empty() {
290            people
291        } else if outgoing {
292            addresses(MMS_TO)
293        } else {
294            [from.clone()].into_iter().filter(|k| !k.is_empty()).collect()
295        };
296        let waiting = int(f, "m_type") == MMS_NOTIFICATION;
297        let parts: Vec<Part> = raw.parts.iter().map(|p| part_of(p, waiting, cap)).collect();
298        let body = parts.iter().filter(|p| p.ct.starts_with("text/plain")).map(|p| p.text.as_str()).collect::<Vec<_>>().join("\n");
299        Text {
300            id: format!("mms-{}", int(f, "_id")),
301            // The multimedia provider counts in seconds.
302            at: int(f, "date") * 1000,
303            thread,
304            direction: if outgoing { "out".into() } else { "in".into() },
305            from: if outgoing || with.len() < 2 { String::new() } else { from },
306            with,
307            body,
308            sub,
309            picture: parts.iter().any(|p| p.ct.starts_with("image/")),
310            parts,
311            fields: f.clone(),
312            addrs: raw.addrs.clone(),
313            ..Text::default()
314        }
315    } else {
316        let address = key(&string(f, "address"));
317        let with = if !people.is_empty() { people } else { [address.clone()].into_iter().filter(|k| !k.is_empty()).collect() };
318        let outgoing = matches!(int(f, "type"), 2 | 4 | 5 | 6);
319        Text {
320            id: format!("sms-{}", int(f, "_id")),
321            at: int(f, "date"),
322            thread,
323            direction: if outgoing { "out".into() } else { "in".into() },
324            from: if outgoing || with.len() < 2 { String::new() } else { address },
325            with,
326            body: string(f, "body"),
327            sub,
328            fields: f.clone(),
329            ..Text::default()
330        }
331    }
332}
333
334fn raws(answer: &Value) -> Vec<Raw> {
335    answer["texts"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default()
336}
337
338/// Each part of a message that travels, copied from Android into the
339/// private folder, hashed, put in the sharing as a sealed blob (never inside
340/// a line), the copy deleted; within `budget` bytes, the first part of a
341/// step always. Whether every part was brought (else the message waits for
342/// the next step).
343fn bring_media(text: &mut Text, vault: &Vault, budget: &mut u64) -> bool {
344    let (folder, key) = vault;
345    for part in text.parts.iter_mut().filter(|p| p.state == "here" && p.hash.is_empty()) {
346        if part.size > *budget && *budget < rules::MEDIA_BATCH {
347            return false;
348        }
349        let incoming = private().join("incoming").join(format!("{}-{}", text.id, part.seq));
350        let answer = crate::steps::java("texts-part", &json!({ "id": int(&part.fields, "_id"), "target": incoming.display().to_string() }).to_string());
351        if answer["allowed"] != true {
352            return false;
353        }
354        if answer["copied"].as_i64().unwrap_or(-1) < 0 {
355            // Gone meanwhile, or never downloaded: said so.
356            part.state = "not-downloaded".into();
357            continue;
358        }
359        let put = sioul_sync::blobs::hash_file(&incoming).map_err(|e| e.to_string()).and_then(|(hash, size)| sioul_sync::blobs::put(folder, key, &incoming, &hash).map(|_| (hash, size)));
360        let _ = std::fs::remove_file(&incoming);
361        match put {
362            Ok((hash, size)) => {
363                part.hash = hash;
364                part.size = size;
365                *budget = budget.saturating_sub(size);
366            }
367            Err(e) => {
368                eprintln!("sioul: texts: {e}");
369                return false;
370            }
371        }
372    }
373    true
374}
375
376/// The conversations' people of a `texts-read` answer, by thread.
377fn threads_of(answer: &Value) -> BTreeMap<String, Vec<String>> {
378    answer["threads"]
379        .as_object()
380        .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())
381        .unwrap_or_default()
382}
383
384/// What a step took of a `texts-read` answer: its texts as they travel, the
385/// last plain and multimedia rows taken, the media bytes put in the sharing.
386struct Taken {
387    texts: Vec<Text>,
388    sms: i64,
389    mms: i64,
390    media: u64,
391    /// The rows the answer held: more than were taken, or a full batch, leaves the import behind.
392    rows: usize,
393}
394
395/// The texts of a `texts-read` answer as they travel, `BATCH` at most, the
396/// media of each multimedia message brought within `MEDIA_BATCH` bytes; a
397/// message whose media do not fit ends the list there, for the next step.
398fn take(answer: &Value, vault: &Vault, region: Option<&phones::Region>) -> Taken {
399    let threads = threads_of(answer);
400    let cap = cap_mb() << 20;
401    let mut budget = rules::MEDIA_BATCH;
402    let raws = raws(answer);
403    let mut taken = Taken { texts: Vec::new(), sms: 0, mms: 0, media: 0, rows: raws.len() };
404    for raw in raws {
405        if taken.texts.len() >= rules::BATCH {
406            break;
407        }
408        let id = int(&raw.fields, "_id");
409        let mut text = text_of(&raw, &threads, region, cap);
410        if raw.kind == "mms" {
411            let before = budget;
412            if !bring_media(&mut text, vault, &mut budget) {
413                break;
414            }
415            taken.media += before - budget;
416            taken.mms = taken.mms.max(id);
417        } else {
418            taken.sms = taken.sms.max(id);
419        }
420        taken.texts.push(text);
421    }
422    taken
423}
424
425/// Texts added to this phone's log, each line once; how many were written.
426fn write_log(here: &str, seal: &Seal, texts: &[Text]) -> usize {
427    let line = |t: &Text| t.line_id();
428    rules::append(&rules::own_file(&root(), rules::LOG, here), texts, seal, Some(&line)).unwrap_or_else(|e| {
429        eprintln!("sioul: texts: {e}");
430        0
431    })
432}
433
434/// One step of the import, the first and every one after: the texts after
435/// the reader's marks, `BATCH` at most (the plain texts first, then the
436/// multimedia ones), their media within `MEDIA_BATCH` bytes, written to this
437/// phone's log. A multimedia message whose media do not fit waits, with every
438/// one after it, for the next step. Whether anything was written.
439fn import_step(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>, now: i64) -> bool {
440    let mut reader = Reader::load();
441    if reader.started == 0 {
442        reader.started = now;
443    }
444    let answer = crate::steps::java("texts-read", &json!({ "sms": reader.sms, "mms": reader.mms, "limit": rules::BATCH }).to_string());
445    if answer["allowed"] != true {
446        return false;
447    }
448    let taken = take(&answer, vault, region);
449    let written = write_log(here, seal, &taken.texts);
450    reader.sms = reader.sms.max(taken.sms);
451    reader.mms = reader.mms.max(taken.mms);
452    reader.done_texts += written as u64;
453    reader.done_media += taken.media;
454    reader.behind = taken.rows >= rules::BATCH || taken.texts.len() < taken.rows;
455    if let Err(e) = reader.save() {
456        eprintln!("sioul: texts: {e}");
457    }
458    if written > 0 {
459        // Never what they say: how many, and how much.
460        eprintln!("sioul: texts: {written} read for your computers ({} so far, {} bytes of media)", reader.done_texts, reader.done_media);
461    }
462    written > 0
463}
464
465/// The rows on the phone at or under the marks that its log lacks, and the
466/// rows its log holds that the phone no longer has; by table.
467#[derive(Debug, Default, PartialEq, Eq)]
468struct Gaps {
469    missing_sms: Vec<i64>,
470    missing_mms: Vec<i64>,
471    gone: Vec<String>,
472}
473
474fn gaps(log: &[Text], sms: &BTreeSet<i64>, mms: &BTreeSet<i64>, sms_mark: i64, mms_mark: i64) -> Gaps {
475    let row = |id: &str| id.split_once('-').map(|(table, n)| (table.to_string(), n.parse::<i64>().unwrap_or(0)));
476    let logged: BTreeSet<(String, i64)> = log.iter().filter(|t| t.deleted == 0).filter_map(|t| row(&t.id)).collect();
477    let marked: BTreeSet<&str> = log.iter().filter(|t| t.deleted > 0).map(|t| t.id.as_str()).collect();
478    let missing = |table: &str, there: &BTreeSet<i64>, mark: i64| -> Vec<i64> { there.iter().copied().filter(|n| *n <= mark && !logged.contains(&(table.to_string(), *n))).take(rules::BATCH).collect() };
479    let gone = logged
480        .iter()
481        .filter(|(table, n)| match table.as_str() {
482            "sms" => *n <= sms_mark && !sms.contains(n),
483            "mms" => *n <= mms_mark && !mms.contains(n),
484            _ => false,
485        })
486        .map(|(table, n)| format!("{table}-{n}"))
487        .filter(|id| !marked.contains(id.as_str()))
488        .collect();
489    Gaps { missing_sms: missing("sms", sms, sms_mark), missing_mms: missing("mms", mms, mms_mark), gone }
490}
491
492/// Once a day: what the phone holds measured for the settings; the texts
493/// deleted on it marked so in its log, a line each (the archive keeps the
494/// text); and a text the reader passed while Android hid it (in the outbox
495/// as a newer one came) read now. Whether a line was written.
496fn look_again(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>, now: i64) -> bool {
497    let mut reader = Reader::load();
498    if now - reader.looked < 86_400_000 {
499        return false;
500    }
501    let answer = crate::steps::java("texts-ids", &json!({ "measure": true }).to_string());
502    if answer["allowed"] != true {
503        return false;
504    }
505    let ids = |name: &str| -> BTreeSet<i64> { answer[name].as_array().map(|a| a.iter().filter_map(Value::as_i64).collect()).unwrap_or_default() };
506    reader.looked = now;
507    reader.measure(&answer, now);
508    if let Err(e) = reader.save() {
509        eprintln!("sioul: texts: {e}");
510    }
511    let log: Vec<Text> = rules::read_file(&rules::own_file(&root(), rules::LOG, here), seal);
512    let found = gaps(&log, &ids("sms"), &ids("mms"), reader.sms, reader.mms);
513    let by_id: BTreeMap<&str, &Text> = log.iter().filter(|t| t.deleted == 0).map(|t| (t.id.as_str(), t)).collect();
514    let marks: Vec<Text> = found
515        .gone
516        .iter()
517        .filter_map(|id| by_id.get(id.as_str()))
518        .map(|t| Text { id: t.id.clone(), at: t.at, direction: t.direction.clone(), with: t.with.clone(), deleted: now, ..Text::default() })
519        .collect();
520    let mut written = write_log(here, seal, &marks);
521    if written > 0 {
522        eprintln!("sioul: texts: {written} deleted on the phone, marked so");
523    }
524    if !found.missing_sms.is_empty() || !found.missing_mms.is_empty() {
525        let answer = crate::steps::java("texts-read", &json!({ "sms_ids": found.missing_sms, "mms_ids": found.missing_mms, "limit": rules::BATCH }).to_string());
526        if answer["allowed"] == true {
527            let taken = take(&answer, vault, region);
528            let caught = write_log(here, seal, &taken.texts);
529            if caught > 0 {
530                eprintln!("sioul: texts: {caught} passed earlier, read now");
531                reader.done_texts += caught as u64;
532                reader.done_media += taken.media;
533                let _ = reader.save();
534            }
535            written += caught;
536        }
537    }
538    written > 0
539}
540
541// ---------------------------------------------------------------- the phone: sending
542
543/// One part's result, as Java wrote it (TextSend.line).
544#[derive(Debug, Clone, Default, Deserialize)]
545#[serde(default)]
546struct PartResult {
547    key: String,
548    kind: String,
549    part: i64,
550    parts: i64,
551    code: i64,
552    uri: String,
553    status: i64,
554    at: i64,
555}
556
557/// Android's code for a part not sent, as the words say it (SmsManager's RESULT_ERROR_…).
558fn reason_of(code: i64) -> &'static str {
559    match code {
560        2 => "radio-off",
561        4 => "no-service",
562        5 => "limit",
563        18 | 30 | 31 => "no-sim",
564        _ => "other",
565    }
566}
567
568/// "content://sms/414" → "sms-414": the row as the log names it.
569fn row_of(uri: &str) -> String {
570    uri.trim().rsplit('/').next().filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())).map(|id| format!("sms-{id}")).unwrap_or_default()
571}
572
573/// Each text's outcomes from its parts' results: sent when every part was;
574/// not sent, and why, when one was not; delivered when the carrier said every
575/// part was received (never "not delivered" for a report that did not come).
576fn outcomes_from(results: &[PartResult]) -> Vec<Outcome> {
577    let mut by: BTreeMap<&str, Vec<&PartResult>> = BTreeMap::new();
578    for r in results.iter().filter(|r| !r.key.is_empty()) {
579        by.entry(r.key.as_str()).or_default().push(r);
580    }
581    let mut out = Vec::new();
582    for (key, parts) in by {
583        let count = parts.iter().map(|p| p.parts.max(1)).max().unwrap_or(1);
584        let sent: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "sent").collect();
585        let at = |list: &[&&PartResult]| list.iter().map(|p| p.at).max().unwrap_or(0);
586        if let Some(failed) = sent.iter().find(|p| p.code != -1) {
587            out.push(Outcome { key: key.into(), state: "failed".into(), at: failed.at, reason: reason_of(failed.code).into(), ..Outcome::default() });
588            continue;
589        }
590        let done: BTreeSet<i64> = sent.iter().map(|p| p.part).collect();
591        if done.len() as i64 >= count {
592            let row = sent.iter().map(|p| row_of(&p.uri)).find(|r| !r.is_empty()).unwrap_or_default();
593            out.push(Outcome { key: key.into(), state: "sent".into(), at: at(&sent), row, ..Outcome::default() });
594            let delivered: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "delivered" && (0..0x20).contains(&p.status)).collect();
595            if delivered.iter().map(|p| p.part).collect::<BTreeSet<_>>().len() as i64 >= count {
596                out.push(Outcome { key: key.into(), state: "delivered".into(), at: at(&delivered), ..Outcome::default() });
597            }
598        }
599    }
600    out
601}
602
603/// Outcomes added to this phone's own file, each (key, state) once.
604fn say(here: &str, seal: &Seal, outcomes: &[Outcome]) -> bool {
605    let id = |o: &Outcome| format!("{}/{}", o.key, o.state);
606    match rules::append(&rules::own_file(&root(), rules::OUTCOME, here), outcomes, seal, Some(&id)) {
607        Ok(n) => n > 0,
608        Err(e) => {
609            eprintln!("sioul: texts: {e}");
610            false
611        }
612    }
613}
614
615/// Java's results copied into this phone's outcomes, and the ledger told.
616fn copy_results(here: &str, seal: &Seal, now: i64) -> bool {
617    let results: Vec<PartResult> = std::fs::read_to_string(private().join(rules::RESULTS)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
618    if results.is_empty() {
619        return false;
620    }
621    let outcomes = outcomes_from(&results);
622    let changed = say(here, seal, &outcomes);
623    let ledger = rules::Ledger::open(&private(), now).ok();
624    for o in &outcomes {
625        if ledger.as_ref().is_some_and(|l| l.state(&o.key) != Some("done")) {
626            let _ = rules::record(&private(), &Claim { key: o.key.clone(), at: now, state: "done".into(), ..Claim::default() });
627        }
628    }
629    changed
630}
631
632/// The requests under each root (`<root>/send/<device>.jsonl`), each key
633/// once, the first root's first: the ledger then decides each once.
634fn requests_from(roots: &[PathBuf], seal: &Seal) -> Vec<Request> {
635    let mut requests: Vec<Request> = Vec::new();
636    for root in roots {
637        for request in rules::read_requests(root, seal) {
638            if !requests.iter().any(|known| known.key == request.key) {
639                requests.push(request);
640            }
641        }
642    }
643    requests
644}
645
646/// Each request decided once, under the ledger's lock (both processes of the
647/// phone may step): claimed and flushed before Android is asked; the others
648/// said (expired, refused, clocks apart) and written in the ledger, so that
649/// they are decided once too. Whether an outcome was written.
650fn decide(here: &str, seal: &Seal, region: Option<&'static phones::Region>, now: i64) -> bool {
651    // The records' copy, then the same requests in their own small files, the
652    // sharing folder's and the copy fetched from its server: one reaches this
653    // phone within its quarter of an hour, whatever the records' size
654    // (docs/database.md, "Sent to the server too").
655    let roots: Vec<PathBuf> = std::iter::once(root()).chain(crate::share::texts_copies()).collect();
656    let requests = requests_from(&roots, seal);
657    if requests.iter().all(|r| r.phone != here) {
658        return false;
659    }
660    let ledger_path = private().join(rules::LEDGER);
661    let mut said = Vec::new();
662    let mut to_send = Vec::new();
663    let claimed = sioul_core::filelock::with_lock(&ledger_path, || -> Result<(), String> {
664        let ledger = rules::Ledger::open(&private(), now)?;
665        for r in &requests {
666            let verdict = rules::verdict(r, here, now, &ledger, region);
667            let (state, reason) = match verdict {
668                Verdict::NotMine | Verdict::Decided => continue,
669                Verdict::Send => {
670                    rules::record(&private(), &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, region), hash: rules::words_hash(&r.body) })?;
671                    to_send.push(r.clone());
672                    continue;
673                }
674                Verdict::Expired => ("expired", ""),
675                Verdict::Clocks => ("clocks", ""),
676                Verdict::BeforeMark => ("refused", "before-mark"),
677                Verdict::Refused(why) => ("refused", why),
678            };
679            rules::record(&private(), &Claim { key: r.key.clone(), at: now, state: state.into(), ..Claim::default() })?;
680            said.push(Outcome { key: r.key.clone(), state: state.into(), at: now, reason: reason.into(), ..Outcome::default() });
681        }
682        Ok(())
683    });
684    if let Err(e) = claimed {
685        // A claim not flushed: nothing is handed to Android.
686        eprintln!("sioul: texts: {e}");
687        return say(here, seal, &said);
688    }
689    let texts = if to_send.is_empty() { Vec::new() } else { rules::read_logs(&root(), seal) };
690    for r in to_send {
691        // The SIM: as asked, else the conversation's last on this phone.
692        let sub = if r.sub >= 0 { r.sub } else { rules::last_sim(&rules::conversation_id(&[phones::key(&r.to, region)]), here, &texts) };
693        let answer = crate::steps::java("texts-send", &json!({ "key": r.key, "to": phones::key(&r.to, region), "body": r.body, "sub": sub }).to_string());
694        if answer["handed"] == true {
695            let _ = rules::record(&private(), &Claim { key: r.key.clone(), at: now_ms(), state: "handed".into(), ..Claim::default() });
696            said.push(Outcome { key: r.key.clone(), state: "handed".into(), at: now_ms(), ..Outcome::default() });
697            eprintln!("sioul: texts: one handed to Android ({} part(s))", answer["parts"].as_i64().unwrap_or(1));
698        } else if answer.is_null() {
699            // No answer from Java at all: whether Android took it is not known. Looked for later, never sent again.
700            eprintln!("sioul: texts: Android's answer did not come; the text is looked for later");
701        } else {
702            let reason = answer["reason"].as_str().unwrap_or("other").to_string();
703            let _ = rules::record(&private(), &Claim { key: r.key.clone(), at: now_ms(), state: "done".into(), ..Claim::default() });
704            said.push(Outcome { key: r.key.clone(), state: "failed".into(), at: now_ms(), reason, ..Outcome::default() });
705        }
706    }
707    say(here, seal, &said)
708}
709
710/// A claim left without Android's word (Sioul stopped between the claim and
711/// the hand-off): looked for among the texts Sioul sent since, by its number
712/// and its words' hash; found, sent; else in doubt. Never sent again.
713fn look_for_lost(here: &str, seal: &Seal, region: Option<&phones::Region>, now: i64) -> bool {
714    let Ok(ledger) = rules::Ledger::open(&private(), now) else { return false };
715    let lost: Vec<&Claim> = ledger.claims.iter().filter(|c| c.state == "claimed" && ledger.state(&c.key) == Some("claimed") && now - c.at >= rules::LOOK_AFTER_MS).collect();
716    if lost.is_empty() {
717        return false;
718    }
719    let since = lost.iter().map(|c| c.at).min().unwrap_or(now) - 60_000;
720    let answer = crate::steps::java("texts-find", &json!({ "since": since }).to_string());
721    if answer["allowed"] != true {
722        return false;
723    }
724    let sent: Vec<Raw> = raws(&answer);
725    let mut said = Vec::new();
726    for claim in lost {
727        let found = sent
728            .iter()
729            .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"), region) == claim.to)
730            .map(|r| format!("sms-{}", int(&r.fields, "_id")));
731        if let Some(outcome) = rules::doubt_after(claim, found, now) {
732            let _ = rules::record(&private(), &Claim { key: claim.key.clone(), at: now, state: "done".into(), ..Claim::default() });
733            said.push(outcome);
734        }
735    }
736    say(here, seal, &said)
737}
738
739/// The quick part of the phone's step, at every exchange: Android's words
740/// copied, the requests decided and sent, a lost claim looked for. Under one
741/// lock for the phone's processes. Whether something was written.
742fn light_step(here: &str, seal: &Seal, region: Option<&'static phones::Region>) -> bool {
743    sioul_core::filelock::with_lock(&private().join(".step"), || {
744        let now = now_ms();
745        let mut changed = copy_results(here, seal, now);
746        changed |= decide(here, seal, region, now);
747        changed |= look_for_lost(here, seal, region, now);
748        changed
749    })
750}
751
752/// The part that may take a while, never inside an exchange: the history's
753/// next batch and its media when due (`import_now`), and the day's look for
754/// deletions. Under its own lock: one import at a time on the phone.
755fn heavy_step(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>) -> bool {
756    sioul_core::filelock::with_lock(&private().join(".import"), || {
757        let now = now_ms();
758        let (texts_changed, plugged) = changes(true);
759        let mut changed = import_now(&Reader::load(), texts_changed, plugged, now) && import_step(here, seal, vault, region, now);
760        changed |= look_again(here, seal, vault, region, now);
761        changed
762    })
763}
764
765/// Java's word on the phone (`Texts.changes`): whether its texts changed
766/// since it was last asked with `clear` (a text came, was sent, deleted or
767/// read: Android's provider watched by the background service; not watched
768/// in this process, or no answer: yes, as at every step before), and whether
769/// the phone is on its charger.
770fn changes(clear: bool) -> (bool, bool) {
771    let answer = crate::steps::java("texts-changes", &json!({ "clear": clear }).to_string());
772    (answer["changed"] != false, answer["plugged"] == true)
773}
774
775/// Whether the import reads the phone's texts now, at `now` (ms;
776/// docs/texts.md, "Reading, on the phone: paced"). A heavy import, the first
777/// (the whole history) or one the last batch left behind (full, or media
778/// that did not fit), only while the phone charges: it may write and send
779/// hundreds of megabytes, on mobile data too. Caught up, when the phone's
780/// texts changed since the last look, and with the day's look for deletions
781/// all the same (a change Android did not say): most steps find nothing new,
782/// and ask Android nothing.
783fn import_now(reader: &Reader, changed: bool, plugged: bool, now: i64) -> bool {
784    if reader.started == 0 || reader.behind { plugged } else { changed || now - reader.looked >= 86_400_000 }
785}
786
787/// A bulk import going on, on the charger or waiting for it (`import_now`):
788/// the sharing's full rounds wait meanwhile (`steps`), so that it does not
789/// restate the records again and again (8 October 2026: a first import made
790/// six full rounds, each sent twice).
791pub(crate) fn importing_in_bulk() -> bool {
792    phone() && sealed().is_some() && Reader::load().behind
793}
794
795/// The background service's step, after its exchange: the quick part, then
796/// the import's next batch. Whether something was written to share now.
797pub(crate) fn phone_step() -> bool {
798    if !phone() {
799        return false;
800    }
801    let Some((here, seal, Some(vault))) = sealed() else { return false };
802    let region = sioul_core::reach::region(&load_config());
803    let quick = light_step(&here, &seal, region);
804    heavy_step(&here, &seal, &vault, region) || quick
805}
806
807// ---------------------------------------------------------------- every device
808
809/// When this device last took its old requests and outcomes out (ms): once a day.
810static TRIMMED: Mutex<i64> = Mutex::new(0);
811/// The import (a phone's window) or the media's fetching (a computer) running in a thread of this process.
812static WORKING: AtomicBool = AtomicBool::new(false);
813
814/// Before an exchange: on a phone, the quick part of its step; on a
815/// computer, how far its clock may run ahead of the phone's. Then, in a
816/// thread of its own that never holds the exchange: the import's next batch
817/// (a phone's window; the background service runs it after its exchange) or
818/// the media brought here (a computer). Once a day, old requests and
819/// outcomes out (texts never).
820pub(crate) fn before_exchange() {
821    let Some((here, seal, vault)) = sealed() else { return };
822    if phone() {
823        light_step(&here, &seal, sioul_core::reach::region(&load_config()));
824    } else {
825        measure_clock();
826    }
827    if vault.is_some() && !crate::steps::in_service() && !WORKING.swap(true, Ordering::AcqRel) {
828        std::thread::spawn(|| {
829            if let Some((here, seal, Some(vault))) = sealed() {
830                if phone() {
831                    heavy_step(&here, &seal, &vault, sioul_core::reach::region(&load_config()));
832                } else {
833                    fetch_media(&seal, &vault);
834                }
835            }
836            WORKING.store(false, Ordering::Release);
837        });
838    }
839    let now = now_ms();
840    let due = TRIMMED.lock().map(|mut last| {
841        let due = now - *last > 86_400_000;
842        if due {
843            *last = now;
844        }
845        due
846    });
847    if due.unwrap_or(false) {
848        let done = rules::trim_own(&root(), &here, &seal, now).and_then(|_| rules::trim_others(&root(), &here, &seal, now));
849        if phone() {
850            let _ = rules::trim_ledger(&private(), now);
851        }
852        if let Err(e) = done {
853            eprintln!("sioul: texts: {e}");
854        }
855    }
856}
857
858// ---------------------------------------------------------------- the media kept here
859
860/// A media file kept here, sealed: `texts/media/<hash>` in the data folder.
861fn media_path(hash: &str) -> PathBuf {
862    root().join(rules::MEDIA).join(hash.chars().filter(char::is_ascii_hexdigit).collect::<String>())
863}
864
865fn here_media(hash: &str) -> bool {
866    !hash.is_empty() && media_path(hash).exists()
867}
868
869/// The phones' logs as last seen by `fetch_media` (each file's size and
870/// time), and whether media were left for next time: nothing new, nothing done.
871static FETCHED: Mutex<(Vec<(u64, u128)>, bool)> = Mutex::new((Vec::new(), true));
872
873/// The media the texts name and this device has not yet: brought from the
874/// sharing's blobs, sealed here, `MEDIA_BATCH` bytes at a time (the first
875/// always); one the sync app has not brought yet waits for next time.
876fn fetch_media(seal: &Seal, vault: &Vault) {
877    let (folder, key) = vault;
878    let mut logs: Vec<(u64, u128)> = std::fs::read_dir(root().join(rules::LOG))
879        .into_iter()
880        .flatten()
881        .filter_map(|entry| entry.ok()?.metadata().ok())
882        .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())))
883        .collect();
884    logs.sort_unstable();
885    if FETCHED.lock().is_ok_and(|seen| seen.0 == logs && !seen.1) {
886        return;
887    }
888    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();
889    wanted.sort();
890    wanted.dedup();
891    let mut budget = rules::MEDIA_BATCH;
892    let (mut brought, mut left) = (0, false);
893    for (hash, size) in wanted {
894        if size > budget && budget < rules::MEDIA_BATCH {
895            left = true;
896            break;
897        }
898        let incoming = private().join("incoming").join(&hash);
899        if let Some(dir) = incoming.parent() {
900            let _ = std::fs::create_dir_all(dir);
901        }
902        if sioul_sync::blobs::get(folder, key, &hash, &incoming).is_err() {
903            left = true;
904            continue;
905        }
906        let bytes = std::fs::read(&incoming);
907        let _ = std::fs::remove_file(&incoming);
908        let Ok(bytes) = bytes else {
909            left = true;
910            continue;
911        };
912        if let Err(e) = write_whole(&media_path(&hash), &seal.seal_bytes(&bytes)) {
913            eprintln!("sioul: texts: {e}");
914            left = true;
915            break;
916        }
917        budget = budget.saturating_sub(bytes.len() as u64);
918        brought += 1;
919    }
920    if let Ok(mut seen) = FETCHED.lock() {
921        *seen = (logs, left);
922    }
923    if brought > 0 {
924        eprintln!("sioul: texts: {brought} media file(s) brought");
925    }
926}
927
928/// Where a media file is opened for the page: Sioul's cache (yours alone), emptied when the page closes.
929fn opened_folder() -> PathBuf {
930    sioul_core::config::cache_dir().join(rules::FOLDER)
931}
932
933/// A media file opened for the page or saved: its plain copy in `opened_folder`; none when it is not here.
934fn open_media(hash: &str, ct: &str) -> Option<PathBuf> {
935    let path = media_path(hash);
936    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");
937    let target = opened_folder().join(format!("{}.{extension}", path.file_name()?.to_string_lossy()));
938    // Opened already for the page: not read and opened again each time a row shows it.
939    if target.exists() {
940        return Some(target);
941    }
942    let (_, seal, _) = sealed()?;
943    let plain = seal.open_bytes(&std::fs::read(&path).ok()?)?;
944    write_whole(&target, &plain).ok()?;
945    Some(target)
946}
947
948// ---------------------------------------------------------------- a computer: the clock
949
950/// How far this computer's clock may run ahead of a phone's (`texts/clock.json`
951/// in the state folder): for each phone, the last time its entry said, and
952/// the least gap seen in the last day.
953#[derive(Debug, Clone, Default, Serialize, Deserialize)]
954struct Clock {
955    #[serde(default)]
956    phones: BTreeMap<String, Gap>,
957}
958
959#[derive(Debug, Clone, Default, Serialize, Deserialize)]
960struct Gap {
961    /// The phone's entry's last `exported` (seconds, the phone's clock).
962    seen: i64,
963    /// The least gap (ms) and when it was seen.
964    least: i64,
965    at: i64,
966}
967
968impl Clock {
969    fn path() -> PathBuf {
970        private().join("clock.json")
971    }
972}
973
974/// At each exchange: the sending phone's entry's time, when it changed,
975/// compared with this computer's clock; the least such gap of the last day
976/// is how far this clock may run ahead of the phone's (transit included: an
977/// upper bound, so that a request looks older, never younger).
978fn measure_clock() {
979    // Every half minute at most: the devices' entries are opened to read it.
980    static LOOKED: Mutex<i64> = Mutex::new(0);
981    let now = now_ms();
982    if let Ok(mut looked) = LOOKED.lock() {
983        if now - *looked < 30_000 {
984            return;
985        }
986        *looked = now;
987    }
988    let Some(phone) = sending_phone() else { return };
989    let mut clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
990    let gap = clock.phones.entry(phone.id.clone()).or_default();
991    if phone.exported <= 0 || phone.exported == gap.seen {
992        return;
993    }
994    let sample = now - phone.exported * 1000;
995    gap.seen = phone.exported;
996    if gap.at == 0 || now - gap.at > 86_400_000 || sample < gap.least {
997        gap.least = sample;
998        gap.at = now;
999    }
1000    if let Ok(text) = serde_json::to_string(&clock) {
1001        let _ = write_whole(&Clock::path(), text.as_bytes());
1002    }
1003}
1004
1005/// How far this clock may run ahead of a phone's (ms): the least gap of the last day, 0 unknown.
1006fn ahead_of(phone: &str) -> i64 {
1007    let clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
1008    clock.phones.get(phone).filter(|g| now_ms() - g.at <= 86_400_000).map_or(0, |g| g.least.max(0))
1009}
1010
1011// ---------------------------------------------------------------- a computer: the phone that sends
1012
1013/// The phones in the sharing, the one chosen to send first, else the one that shared last.
1014fn phones() -> Vec<sioul_sync::devices::Entry> {
1015    let Some((_, Some((folder, key)))) = crate::share::vault() else { return Vec::new() };
1016    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();
1017    let chosen = choice();
1018    all.sort_by_key(|d| (d.id != chosen.phone, -d.exported));
1019    all
1020}
1021
1022fn sending_phone() -> Option<sioul_sync::devices::Entry> {
1023    if demo() && !phone() {
1024        // The demo's stand-in phone, which shared two minutes ago.
1025        let exported = jiff::Timestamp::now().as_second() - 120;
1026        return Some(sioul_sync::devices::Entry { id: "demo-phone".into(), name: String::new(), kind: sioul_sync::devices::PHONE.into(), exported, ..Default::default() });
1027    }
1028    phones().into_iter().next()
1029}
1030
1031// ---------------------------------------------------------------- a computer: the page
1032
1033fn names(senders: std::rc::Rc<Senders>) -> impl Fn(&str) -> Option<String> {
1034    move |key: &str| {
1035        let judged = senders.judge_number(key);
1036        (!judged.card.trim().is_empty()).then_some(judged.card)
1037    }
1038}
1039
1040thread_local! {
1041    /// The settings and your lists and address books, as the page last read
1042    /// them: read again when the settings' file changed, or after a minute.
1043    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) };
1044}
1045
1046/// The settings and your senders for the page's looks (`LOOKS`): each
1047/// opening of a conversation read them twice, on the window's thread.
1048fn looks() -> (std::rc::Rc<Config>, std::rc::Rc<Senders>) {
1049    let stamp = std::fs::metadata(config_dir().join("config.toml")).ok().map(|m| (m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
1050    LOOKS.with(|cell| {
1051        let mut kept = cell.borrow_mut();
1052        if let Some((s, at, config, senders)) = kept.as_ref()
1053            && *s == stamp
1054            && at.elapsed() < std::time::Duration::from_secs(60)
1055        {
1056            return (std::rc::Rc::clone(config), std::rc::Rc::clone(senders));
1057        }
1058        let config = std::rc::Rc::new(load_config());
1059        let senders = std::rc::Rc::new(Senders::load(&config));
1060        *kept = Some((stamp, std::time::Instant::now(), std::rc::Rc::clone(&config), std::rc::Rc::clone(&senders)));
1061        (config, senders)
1062    })
1063}
1064
1065/// The texts, the requests and their outcomes as last read, kept while their
1066/// files stay as they were (size and time) for this device: the whole
1067/// history is opened once, not at each look of the page (9,800 sealed lines
1068/// read twice per conversation opened froze the window for seconds).
1069struct Kept {
1070    here: String,
1071    stamp: Vec<(PathBuf, u64, std::time::SystemTime)>,
1072    texts: std::sync::Arc<Vec<Text>>,
1073    requests: std::sync::Arc<Vec<Request>>,
1074    outcomes: std::sync::Arc<Vec<Outcome>>,
1075}
1076
1077static KEPT: Mutex<Option<Kept>> = Mutex::new(None);
1078
1079/// Each record file of the three stores, its size and time.
1080fn stamps(root: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
1081    let mut all = Vec::new();
1082    for store in [rules::LOG, rules::SEND, rules::OUTCOME] {
1083        for entry in std::fs::read_dir(root.join(store)).into_iter().flatten().flatten() {
1084            if let Ok(meta) = entry.metadata() {
1085                all.push((entry.path(), meta.len(), meta.modified().unwrap_or(std::time::UNIX_EPOCH)));
1086            }
1087        }
1088    }
1089    all.sort();
1090    all
1091}
1092
1093/// The texts, the requests and their outcomes (`KEPT`), read again only when a file changed.
1094#[allow(clippy::type_complexity)]
1095fn records(here: &str, seal: &Seal) -> (std::sync::Arc<Vec<Text>>, std::sync::Arc<Vec<Request>>, std::sync::Arc<Vec<Outcome>>) {
1096    let root = root();
1097    let stamp = stamps(&root);
1098    let mut kept = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1099    if let Some(k) = kept.as_ref().filter(|k| k.here == here && k.stamp == stamp) {
1100        return (std::sync::Arc::clone(&k.texts), std::sync::Arc::clone(&k.requests), std::sync::Arc::clone(&k.outcomes));
1101    }
1102    let texts = std::sync::Arc::new(rules::read_logs(&root, seal));
1103    let requests = std::sync::Arc::new(rules::read_requests(&root, seal));
1104    let outcomes = std::sync::Arc::new(rules::read_outcomes(&root, seal));
1105    *kept = Some(Kept { here: here.to_string(), stamp, texts: std::sync::Arc::clone(&texts), requests: std::sync::Arc::clone(&requests), outcomes: std::sync::Arc::clone(&outcomes) });
1106    (texts, requests, outcomes)
1107}
1108
1109/// The Texts page's list (TextsPage.qml): {can (the part on, a phone that
1110/// sends), phone: {id, name, shared, said}, conversations, said, never,
1111/// empty (the list's words when it has no row)}; without them, `can` false and
1112/// why. With `query`, the conversations holding a text with all its words (or
1113/// whose people's names or numbers do), each with the newest such text as its
1114/// last words.
1115fn view(query: &str) -> Value {
1116    let tr = tr();
1117    let Some((here, seal, _)) = sealed() else { return json!({ "can": false, "said": tr.text("texts-page-off", None), "conversations": [] }) };
1118    let Some(phone) = sending_phone() else { return json!({ "can": false, "said": tr.text("texts-page-no-phone", None), "conversations": [] }) };
1119    let (config, senders) = looks();
1120    let now = Zoned::now();
1121    let name_of = names(senders);
1122    let shared = (phone.exported > 0).then_some(phone.exported * 1000);
1123    let v = rules::Viewer { now: &now, tr, region: sioul_core::reach::region(&config), name_of: &name_of, phone_shared: shared, media_here: &here_media };
1124    let (texts, requests, _) = records(&here, &seal);
1125    let mut conversations = rules::conversations(&texts, &requests, &v);
1126    let query = query.trim();
1127    if !query.is_empty() {
1128        // The newest text found in each conversation: its words, under its name.
1129        let mut found: BTreeMap<String, String> = BTreeMap::new();
1130        for f in rules::search(query, &texts, &requests, &[], &v, usize::MAX) {
1131            found.entry(f.conversation).or_insert_with(|| f.message.body.chars().take(80).collect());
1132        }
1133        conversations.retain(|c| found.contains_key(&c.id));
1134        for c in &mut conversations {
1135            if let Some(words) = found.get(&c.id).filter(|w| !w.is_empty()) {
1136                c.last = words.clone();
1137            }
1138        }
1139    }
1140    let mut rows = with_drafts(conversations, &sioul_core::textdraft::all(&seal), &v);
1141    if !query.is_empty()
1142        && let Value::Array(list) = &mut rows
1143    {
1144        // A draft's conversation with no text yet: kept when its name or number holds the words.
1145        let words = sioul_core::rules::fold(query);
1146        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)));
1147    }
1148    let shared_at = shared.map(|ms| rules::time_of_day(ms, &now)).unwrap_or_default();
1149    let mut args = sioul_core::i18n::args();
1150    args.set("time", shared_at.clone());
1151    json!({
1152        "can": true,
1153        "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)) } },
1154        "conversations": rows,
1155        "said": "",
1156        "never": tr.text("texts-page-never", None),
1157        "empty": tr.text(if query.is_empty() { "texts-page-empty" } else { "texts-search-none" }, None),
1158    })
1159}
1160
1161/// The conversations with the drafts an AI agent wrote for them (`textdraft`,
1162/// docs/mcp.md, "Texts"): how many wait in each, said under its last words;
1163/// a draft to someone with no conversation yet makes one, first.
1164fn with_drafts(conversations: Vec<rules::Conversation>, drafts: &[sioul_core::textdraft::TextDraft], v: &rules::Viewer) -> Value {
1165    let waiting = |id: &str| drafts.iter().filter(|d| d.conversation == id).count();
1166    let mut out: Vec<Value> = Vec::new();
1167    let mut new: Vec<&str> = drafts.iter().map(|d| d.conversation.as_str()).filter(|id| !conversations.iter().any(|c| c.id == *id)).collect();
1168    new.sort_unstable();
1169    new.dedup();
1170    for id in new {
1171        let title = id.split(',').map(|k| (v.name_of)(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ");
1172        out.push(json!({ "id": id, "with": [id], "title": title, "last": "", "when": "", "at": 0, "group": false, "drafts": waiting(id) }));
1173    }
1174    for c in conversations {
1175        let mut row = serde_json::to_value(&c).unwrap_or_default();
1176        row["drafts"] = json!(waiting(&c.id));
1177        out.push(row);
1178    }
1179    Value::Array(out)
1180}
1181
1182/// One conversation (TextsPage.qml): {id, title, messages, can_write, group}.
1183fn conversation(id: &str) -> Value {
1184    let Some((here, seal, _)) = sealed() else { return json!({ "messages": [] }) };
1185    let (config, senders) = looks();
1186    let now = Zoned::now();
1187    let name_of = names(senders);
1188    let phone = sending_phone();
1189    let shared = phone.as_ref().filter(|p| p.exported > 0).map(|p| p.exported * 1000);
1190    let region = sioul_core::reach::region(&config);
1191    let v = rules::Viewer { now: &now, tr: tr(), region, name_of: &name_of, phone_shared: shared, media_here: &here_media };
1192    let (texts, requests, outcomes) = records(&here, &seal);
1193    let messages = rules::messages(id, &texts, &requests, &outcomes, &v);
1194    let with: Vec<String> = id.split(',').map(str::to_string).collect();
1195    let title = with.iter().map(|k| name_of(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, region))).collect::<Vec<_>>().join(", ");
1196    // What an AI agent drafted for it: waiting for you, never sent by itself.
1197    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();
1198    json!({
1199        "id": id,
1200        "title": title,
1201        "messages": messages,
1202        "drafts": drafts,
1203        "can_write": with.len() == 1,
1204        "group": with.len() > 1,
1205    })
1206}
1207
1208/// A draft an AI agent wrote, used (its words, for the box: sending stays
1209/// yours) or discarded: taken out of the drafts either way. {body}, "" when gone.
1210fn draft_taken(id: &str) -> Value {
1211    let Some((_, seal, _)) = sealed() else { return json!({ "body": "" }) };
1212    let body = sioul_core::textdraft::all(&seal).into_iter().find(|d| d.id == id).map(|d| d.body).unwrap_or_default();
1213    let _ = sioul_core::textdraft::remove(id, &seal);
1214    json!({ "body": body })
1215}
1216
1217/// A text written here for the phone to send: refused at once when the
1218/// phone would refuse it (said why), else a request with a key of its own in
1219/// this device's file, which the sharing carries. {said, shared, key}.
1220fn send(to: &str, body: &str, again: &str) -> (Value, bool) {
1221    let tr = tr();
1222    let Some((here, seal, _)) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
1223    let Some(phone) = sending_phone() else { return (json!({ "said": tr.text("texts-page-no-phone", None), "shared": false }), false) };
1224    let region = sioul_core::reach::region(&load_config());
1225    if let Some(why) = rules::refusal(to, body, region) {
1226        return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
1227    }
1228    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(), device: String::new() };
1229    match rules::append(&rules::own_file(&root(), rules::SEND, &here), std::slice::from_ref(&request), &seal, None) {
1230        Ok(_) => {
1231            // Its small file to the server now, not behind the records.
1232            crate::share::texts_now();
1233            (json!({ "said": "", "shared": true, "key": request.key }), true)
1234        }
1235        Err(e) => (json!({ "said": e, "shared": false }), false),
1236    }
1237}
1238
1239/// Send again: a request of its own, a new key, the same number and words.
1240fn again(key: &str) -> (Value, bool) {
1241    let Some((_, seal, _)) = sealed() else { return (json!({ "said": tr().text("texts-page-off", None), "shared": false }), false) };
1242    match rules::read_requests(&root(), &seal).into_iter().find(|r| r.key == key) {
1243        Some(old) => send(&old.to, &old.body, key),
1244        None => (json!({ "said": "", "shared": false }), false),
1245    }
1246}
1247
1248/// How many texts a draft takes: {parts, said}.
1249fn parts(body: &str) -> Value {
1250    let (count, gsm) = rules::parts(body);
1251    let mut args = sioul_core::i18n::args();
1252    args.set("count", i64::try_from(count).unwrap_or(0));
1253    args.set("characters", i64::try_from(body.chars().count()).unwrap_or(0));
1254    let said = if count == 0 { String::new() } else { tr().text(if gsm { "texts-parts" } else { "texts-parts-unicode" }, Some(&args)) };
1255    json!({ "parts": count, "said": said })
1256}
1257
1258/// "1.2 MB".
1259fn size_words(bytes: u64) -> String {
1260    sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX))
1261}
1262
1263/// What this device keeps of the texts, for its part's line in the sharing
1264/// panel ("Keeps 4,830 texts and 210 MB of media here."); "" when none.
1265pub(crate) fn kept_words() -> String {
1266    let Some((here, seal, _)) = sealed() else { return String::new() };
1267    let (texts, _, _) = records(&here, &seal);
1268    if texts.is_empty() {
1269        return String::new();
1270    }
1271    let (count, bytes) = rules::kept(&texts, &here_media);
1272    let mut args = sioul_core::i18n::args();
1273    args.set("count", i64::try_from(count).unwrap_or(0));
1274    args.set("size", size_words(bytes));
1275    tr().text("texts-kept", Some(&args))
1276}
1277
1278// ---------------------------------------------------------------- Settings ▸ This phone ▸ Texts
1279
1280/// The phone's tab (TextsSetup.qml): {android, state (each permission, the
1281/// SIMs), on (the part here), sharing, progress (what the phone holds and how
1282/// much has gone), importing (more to go), cap (the setting row of the largest
1283/// media file brought)}.
1284fn setup() -> Value {
1285    let here = sioul_sync::share::Here::load(&state_dir());
1286    let android = cfg!(target_os = "android");
1287    let state = if android { crate::steps::java("texts-state", "{}") } else { Value::Null };
1288    let mut reader = Reader::load();
1289    // Measured before the import begins; then each day, with the look for deletions.
1290    if android && state["read"] == true && reader.measured == 0 {
1291        let answer = crate::steps::java("texts-ids", &json!({ "measure": true }).to_string());
1292        if answer["allowed"] == true {
1293            reader.measure(&answer, now_ms());
1294            let _ = reader.save();
1295        }
1296    }
1297    let tr = tr();
1298    let mut args = sioul_core::i18n::args();
1299    args.set("count", i64::try_from(reader.total_texts).unwrap_or(0));
1300    args.set("size", size_words(reader.total_media));
1301    args.set("done", i64::try_from(reader.done_texts).unwrap_or(0));
1302    args.set("done_size", size_words(reader.done_media));
1303    // A heavy import waits for the charger (`import_now`): said, calmly.
1304    let goes_now = !android || (reader.started > 0 && !reader.behind) || changes(false).1;
1305    let progress = match (reader.measured, reader.done_texts) {
1306        (0, _) => String::new(),
1307        (_, 0) if goes_now => tr.text("texts-setup-holds", Some(&args)),
1308        (_, 0) => format!("{} {}", tr.text("texts-setup-holds", Some(&args)), tr.text("texts-setup-charging", None)),
1309        (_, done) if done >= reader.total_texts => tr.text("texts-setup-done", Some(&args)),
1310        _ if goes_now => tr.text("texts-setup-progress", Some(&args)),
1311        _ => tr.text("texts-setup-progress-charging", Some(&args)),
1312    };
1313    let choices: Vec<Value> = CAPS_MB
1314        .iter()
1315        .map(|mb| {
1316            let mut args = sioul_core::i18n::args();
1317            args.set("size", size_words(mb << 20));
1318            json!({ "value": mb.to_string(), "label": tr.text("texts-setup-cap-value", Some(&args)) })
1319        })
1320        .collect();
1321    json!({
1322        "android": android,
1323        "state": state,
1324        "on": here.shares(rules::PART, &load_config()),
1325        "sharing": here.folder.is_some(),
1326        "progress": progress,
1327        "importing": reader.measured > 0 && reader.done_texts < reader.total_texts,
1328        "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 },
1329    })
1330}
1331
1332/// The largest media file brought to your computers, chosen on the phone (MB).
1333fn set_cap(value: &str) -> Result<(), String> {
1334    let mb: u64 = value.trim().parse().map_err(|_| format!("{value}?"))?;
1335    let path = choice_path();
1336    sioul_core::filelock::with_lock(&path, || {
1337        let mut chosen = choice();
1338        chosen.media_cap_mb = (mb != rules::MEDIA_CAP_MB).then_some(mb.clamp(1, sioul_sync::blobs::LARGEST >> 20));
1339        write_whole(&path, toml::to_string(&chosen).map_err(|e| e.to_string())?.as_bytes())
1340    })
1341}
1342
1343/// What the window asks (`Sioul::texts`): "view" {query}, "conversation" {id},
1344/// "send" {to, body}, "again" {key}, "parts" {body}, "media" {hash, ct} (its
1345/// address, to show or open it), "save-media" {hash, ct, path}, "close" (the
1346/// page closed: the media opened for it deleted), "setup", "ask" (Android's
1347/// question for the permissions), "part" {on}, "cap" {value}. Its JSON, and
1348/// whether what travels changed (to share now).
1349pub(crate) fn call(verb: &str, json: &str) -> (String, bool) {
1350    let asked: Value = serde_json::from_str(json).unwrap_or_default();
1351    let text = |key: &str| asked[key].as_str().unwrap_or_default().to_string();
1352    let (answer, shared) = match verb {
1353        "view" => (view(&text("query")), false),
1354        "conversation" => (conversation(&text("id")), false),
1355        "send" => send(&text("to"), &text("body"), ""),
1356        "again" => again(&text("key")),
1357        "parts" => (parts(&text("body")), false),
1358        // An AI agent's draft: its words for the box, or gone; nothing is sent.
1359        "draft-use" | "draft-discard" => (draft_taken(&text("id")), false),
1360        "media" => {
1361            let url = open_media(&text("hash"), &text("ct")).map(|p| crate::backend::file_url(&p)).unwrap_or_default();
1362            (json!({ "url": url }), false)
1363        }
1364        "save-media" => {
1365            let target = crate::backend::local_path(&text("path"));
1366            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());
1367            (json!({ "saved": saved }), false)
1368        }
1369        "close" => {
1370            let _ = std::fs::remove_dir_all(opened_folder());
1371            (json!({}), false)
1372        }
1373        "setup" => (setup(), false),
1374        "ask" => {
1375            crate::steps::java("texts-ask", "{}");
1376            (setup(), false)
1377        }
1378        "part" => {
1379            let problem = crate::share::set_part(rules::PART, asked["on"].as_bool().unwrap_or(false));
1380            let mut tab = setup();
1381            tab["said"] = json!(problem);
1382            (tab, problem.is_empty())
1383        }
1384        "cap" => {
1385            let problem = set_cap(&text("value")).err().unwrap_or_default();
1386            let mut tab = setup();
1387            tab["said"] = json!(problem);
1388            (tab, false)
1389        }
1390        _ => (json!({}), false),
1391    };
1392    (answer.to_string(), shared)
1393}
1394
1395/// Whether the Texts page has something to show here: the part on, and a phone that sends.
1396pub(crate) fn readable() -> bool {
1397    sealed().is_some() && sending_phone().is_some()
1398}
1399
1400/// A person's conversation on the Texts page, from their numbers as written:
1401/// the first whole one, keyed; "" while texts are not read here.
1402pub(crate) fn conversation_of(numbers: &[String]) -> String {
1403    if sealed().is_none() {
1404        return String::new();
1405    }
1406    let region = sioul_core::reach::region(&load_config());
1407    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()
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412    use super::*;
1413
1414    /// Thursday 8 October 2026, 10:00 in Paris.
1415    const NOW: i64 = 1_791_446_400_000;
1416
1417    fn part(key: &str, kind: &str, part: i64, parts: i64, code: i64, uri: &str, status: i64, at: i64) -> PartResult {
1418        PartResult { key: key.into(), kind: kind.into(), part, parts, code, uri: uri.into(), status, at }
1419    }
1420
1421    /// A request found in its small file (the sharing folder's, the server's
1422    /// copy) before the records bring it, read beside the records' copy:
1423    /// each key once, its device by its file's name.
1424    #[test]
1425    fn requests_are_read_from_their_small_files_too_each_once() {
1426        let base = std::env::temp_dir().join(format!("sioul-texts-copies-{}", std::process::id()));
1427        let _ = std::fs::remove_dir_all(&base);
1428        let seal = Seal::Key(TextSeal::new(&[3u8; 32]));
1429        let desk = "0f8fad5b-d9cb-469f-a165-70867728950e";
1430        let request = |key: &str| Request { key: key.into(), phone: "phone".into(), to: "0199001234".into(), body: "On my way".into(), sub: -1, written: NOW, ahead: 0, again: String::new(), device: String::new() };
1431        let (records, folder, fetched) = (base.join("records"), base.join("folder"), base.join("fetched"));
1432        rules::append(&rules::own_file(&records, rules::SEND, desk), &[request("a")], &seal, None).unwrap();
1433        rules::append(&rules::own_file(&folder, rules::SEND, desk), &[request("a"), request("b")], &seal, None).unwrap();
1434        rules::append(&rules::own_file(&fetched, rules::SEND, desk), &[request("a"), request("b"), request("c")], &seal, None).unwrap();
1435        let read = requests_from(&[records.clone(), folder, fetched], &seal);
1436        assert_eq!(read.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), ["a", "b", "c"]);
1437        assert!(read.iter().all(|r| r.device == desk), "{read:?}");
1438        // Nothing but the records: as before.
1439        assert_eq!(requests_from(&[records, base.join("none")], &seal).len(), 1);
1440        let _ = std::fs::remove_dir_all(&base);
1441    }
1442
1443    #[test]
1444    fn a_text_s_parts_make_one_outcome_each_and_delivered_only_when_reported() {
1445        let results = [
1446            part("a", "sent", 0, 2, -1, "content://sms/414", -1, NOW),
1447            part("a", "sent", 1, 2, -1, "content://sms/414", -1, NOW + 1000),
1448            part("a", "delivered", 0, 2, -1, "", 0, NOW + 5000),
1449            part("b", "sent", 0, 1, -1, "content://sms/415", -1, NOW),
1450            part("b", "delivered", 0, 1, -1, "", 0, NOW + 3000),
1451            part("c", "sent", 0, 1, 4, "", -1, NOW),
1452            part("d", "sent", 0, 2, -1, "content://sms/416", -1, NOW),
1453        ];
1454        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();
1455        assert_eq!(said, [
1456            ("a".to_string(), "sent".to_string(), "sms-414".to_string()),
1457            ("b".into(), "sent".into(), "sms-415".into()),
1458            ("b".into(), "delivered".into(), String::new()),
1459            ("c".into(), "failed".into(), "no-service".into()),
1460        ], "a: one part of two delivered, no word; d: one part of two sent, not yet");
1461    }
1462
1463    /// The rows as Java writes them (android/jvm-checks/TextsCheck.java):
1464    /// every column kept; a text's people from its conversation; a received
1465    /// multimedia message's sender, words and picture, a big video left on
1466    /// the phone, one still to download said; a group read as a group.
1467    #[test]
1468    fn what_java_reads_is_kept_whole_and_read_here() {
1469        let json = r#"{"texts":[
1470            {"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}},
1471            {"kind":"mms","fields":{"_id":31,"thread_id":9,"date":1791446340,"msg_box":1,"m_type":132,"sub_id":1},
1472             "addrs":[{"address":"+33465710042","type":137,"charset":106},{"address":"insert-address-token","type":151,"charset":106}],
1473             "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}]},
1474            {"kind":"mms","fields":{"_id":32,"thread_id":11,"date":1791446350,"msg_box":1,"m_type":130},"addrs":[{"address":"+33465710042","type":137}],"parts":[]},
1475            {"kind":"sms","fields":{"_id":413,"thread_id":12,"address":"+33465710042","date":1791446360000,"type":1,"body":"Pour le groupe."}}],
1476            "threads":{"7":["0199001234"],"9":["+33465710042"],"12":["+33465710042","+33199001234"]},"sms":413,"mms":32,"allowed":true}"#;
1477        let answer: Value = serde_json::from_str(json).unwrap();
1478        let region = phones::region_named("FR");
1479        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();
1480        let texts: Vec<Text> = raws(&answer).iter().map(|r| text_of(r, &threads, region, 10 << 20)).collect();
1481        let sms = &texts[0];
1482        assert_eq!((sms.id.as_str(), sms.with.as_slice(), sms.sub, sms.direction.as_str(), sms.at), ("sms-412", &["+33199001234".to_string()][..], 2, "in", 1_791_446_340_000));
1483        assert_eq!(sms.fields.len(), 13, "every column kept");
1484        let mms = &texts[1];
1485        assert_eq!((mms.id.as_str(), mms.at, mms.with.as_slice(), mms.body.as_str(), mms.picture, mms.sub), ("mms-31", 1_791_446_340_000, &["+33465710042".to_string()][..], "Le colis est arrivé.", true, 1));
1486        assert_eq!(mms.parts.iter().map(|p| p.state.as_str()).collect::<Vec<_>>(), ["text", "here", "too-big", "text"]);
1487        assert_eq!((mms.parts[1].name.as_str(), mms.parts[2].name.as_str(), mms.parts[1].size), ("a.jpg", "b.mp4", 220_000));
1488        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");
1489        assert_eq!(mms.addrs.len(), 2);
1490        assert_eq!((texts[2].parts.len(), texts[2].with.as_slice()), (0, &["+33465710042".to_string()][..]), "nothing to download yet");
1491        assert_eq!(texts[3].with, ["+33465710042", "+33199001234"], "a group, from its conversation");
1492        assert_eq!(texts[3].from, "+33465710042");
1493        let waiting = Raw { kind: "mms".into(), parts: vec![[("ct".to_string(), json!("image/png")), ("size".to_string(), json!(-1))].into_iter().collect()], ..Raw::default() };
1494        assert_eq!(text_of(&waiting, &threads, region, 10 << 20).parts[0].state, "not-downloaded");
1495        assert_eq!(row_of("content://sms/414"), "sms-414");
1496        assert_eq!(row_of(""), "");
1497    }
1498
1499    /// The daily look: a row the reader passed while Android hid it is read
1500    /// now (never one after the marks: the import's); a row gone from the
1501    /// phone is marked once.
1502    #[test]
1503    fn the_daily_look_finds_what_was_passed_and_what_went() {
1504        let row = |id: &str| Text { id: id.into(), at: NOW, ..Text::default() };
1505        let log = vec![row("sms-1"), row("sms-2"), row("sms-4"), row("mms-3"), Text { deleted: NOW, ..row("sms-2") }, row("sms-5")];
1506        let sms: BTreeSet<i64> = [1, 3, 4, 6, 9].into();
1507        let found = gaps(&log, &sms, &BTreeSet::new(), 6, 3);
1508        assert_eq!(found.missing_sms, [3, 6], "passed while hidden; 9 is after the mark");
1509        assert!(found.missing_mms.is_empty());
1510        assert_eq!(found.gone, ["mms-3", "sms-5"], "sms-2 is marked already");
1511    }
1512
1513    /// The import asks Android only when the phone's texts changed; a heavy
1514    /// one (the first, of the whole history, or one a full batch left behind)
1515    /// only while the phone charges.
1516    #[test]
1517    fn a_heavy_import_waits_for_the_charger() {
1518        let first = Reader::default();
1519        assert!(!import_now(&first, true, false, NOW), "the whole history, on the battery: it waits");
1520        assert!(import_now(&first, false, true, NOW), "on the charger");
1521        let caught_up = Reader { started: NOW - 1_000, looked: NOW - 3_600_000, ..Reader::default() };
1522        assert!(import_now(&caught_up, true, false, NOW), "a new text, on the battery");
1523        assert!(!import_now(&caught_up, false, false, NOW) && !import_now(&caught_up, false, true, NOW), "nothing changed: Android not asked");
1524        assert!(import_now(&caught_up, false, false, NOW + 86_400_000), "the day's look: read all the same");
1525        let behind = Reader { started: NOW - 1_000, behind: true, ..caught_up.clone() };
1526        assert!(!import_now(&behind, true, false, NOW), "a full batch left more: on the charger only");
1527        assert!(import_now(&behind, false, true, NOW), "on the charger, batch after batch");
1528        // A reader written before this mark: caught up.
1529        let older: Reader = serde_json::from_str(r#"{"version":2,"sms":10,"mms":3,"started":1}"#).unwrap();
1530        assert!(!older.behind && import_now(&older, true, false, NOW));
1531    }
1532
1533    #[test]
1534    fn nothing_runs_on_a_computer_without_the_part() {
1535        if phone() || demo() {
1536            return;
1537        }
1538        assert!(!phone_step());
1539        let (answer, shared) = call("send", r#"{"to":"01 99 00 12 34","body":"Bonjour"}"#);
1540        assert!(!shared, "{answer}");
1541    }
1542}
1543