Skip to main content

sioul_core/
phonemsgs.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The phone's messages on your computers (docs/android.md, "Messages on
5//! your computers"; docs/database.md, "The phone's messages"; docs/porch.md,
6//! "From your phone"; research: docs/research/sms.md, phase a).
7//!
8//! The listener (AppNotes.java, `appnotes`) sees each notification of
9//! another app. For the apps switched on for your computers (the phone's SMS
10//! app unless you say otherwise; any other app, a chat's, when you choose
11//! it), and while the part "Messages from your phone" is on, the phone writes
12//! a **line** per message in its own log, `phone-messages/log/<device>.jsonl`
13//! (`lines_of`, `carry_into`), which the sharing carries, sealed, to your
14//! other devices. A line says who wrote, when, in which conversation, and
15//! the words, cut at 1,000 characters; or only who and when, for an app set
16//! so; "a picture" for a picture, never the picture.
17//!
18//! **Never**: a code or an approval (its line says a code came, and that it
19//! stays on the phone), a copy Android redacted (the same), anything of a
20//! blocked sender or of a conversation held for good, a notification its
21//! app marked secret, ongoing ones, Sioul's own, a button or a tap. Each line
22//! is padded to a multiple of 256 bytes before it is sealed (`padded`): the
23//! server learns no message's length.
24//!
25//! A computer's Porch lists them under "From your phone" (`lines`): one line
26//! per conversation, part of the day and day, once the phone let the
27//! notification through (`Line::shows`) and while its sender may reach you
28//! by that device's matrix (`reaches_now`), never as a notification of its
29//! own, never counted. **Seen** is each device's own log of lines,
30//! `phone-messages/seen/<device>.jsonl`, written and read as the calls' are
31//! (`calls::mark_seen`, `calls::read_seen` with this folder): seen on one
32//! device, gone from all. A phone takes its own lines out after `KEPT_DAYS`;
33//! any device takes another's out `LATE_DAYS` later (`trim_own`,
34//! `trim_others`), so that a phone gone for good leaves nothing behind for
35//! ever.
36//!
37//! What the phone sends, per app, is its own choice (`Choices`,
38//! `phone-messages.toml` in its configuration), never shared.
39//!
40//! Each line names the notification it came in by the phone's own name for
41//! it (`Line::notice`, `replies::notice`), and the phone says beside, in its
42//! own `phone-messages/live/<device>.jsonl`, whether that notification offers
43//! a Reply and a Mark as read, or went (`replies::Live`): what a reply from
44//! a computer names and needs (`replies`).
45
46use crate::appnotes::{self, Decision, Kind, Person, Posted, Sender, Why};
47use crate::attention::{self, Attention, Column, Event, Level, Row, Source};
48use crate::calls;
49use crate::i18n::Translator;
50use crate::phones::{self, Region};
51use crate::reach::{Channel, Who};
52use jiff::Zoned;
53use serde::{Deserialize, Serialize};
54use std::collections::{BTreeMap, BTreeSet};
55use std::path::{Path, PathBuf};
56
57/// The sharing's part that carries them.
58pub const PART: &str = "phone-messages";
59/// Their folder, in the state folder.
60pub const FOLDER: &str = "phone-messages";
61/// The folder of the phones' logs, one file per phone, written by that phone alone; shared.
62pub const LOG: &str = "log";
63/// The folder of the lines marked seen, one file per device; shared. The
64/// calls' name: their functions read and write it under this folder too.
65pub const SEEN_LOG: &str = calls::SEEN_LOG;
66/// A phone takes its own lines out after this many days.
67pub const KEPT_DAYS: i64 = 7;
68/// Another device's lines are taken out by any device this many days later.
69pub const LATE_DAYS: i64 = 3;
70/// A message's words are cut after this many characters.
71pub const TEXT_MAX: usize = 1000;
72/// Each line's length, in bytes, is a multiple of this.
73pub const PAD_STEP: usize = 256;
74/// The phone's choices, in its configuration folder.
75pub const CHOICES_FILE: &str = "phone-messages.toml";
76/// Android's `Notification.VISIBILITY_SECRET`: kept off the lock screen, kept on the phone.
77pub const SECRET: i32 = -1;
78/// A name is cut after this many characters.
79const NAME_MAX: usize = 80;
80/// A day, in milliseconds.
81const DAY_MS: i64 = 86_400_000;
82
83/// The folder of the phones' messages.
84pub fn folder() -> PathBuf {
85    crate::config::state_dir().join(FOLDER)
86}
87
88// ---------------------------------------------------------------- the phone's choices
89
90/// What an app's notifications send to your computers.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93pub enum Send {
94    /// As Sioul does unless you say: the words for the phone's SMS app, nothing for every other app.
95    #[default]
96    Usual,
97    /// Nothing leaves the phone.
98    Off,
99    /// Who wrote and when, without the words.
100    Who,
101    /// Who, when and the words.
102    Words,
103}
104
105impl Send {
106    pub const ALL: [Send; 4] = [Send::Usual, Send::Off, Send::Who, Send::Words];
107
108    pub fn id(self) -> &'static str {
109        match self {
110            Send::Usual => "usual",
111            Send::Off => "off",
112            Send::Who => "who",
113            Send::Words => "words",
114        }
115    }
116
117    pub fn read(id: &str) -> Option<Send> {
118        Send::ALL.into_iter().find(|s| s.id() == id)
119    }
120
121    /// What it sends: "as usual" made the words for the SMS app, nothing for the others.
122    pub fn resolved(self, sms_app: bool) -> Send {
123        match self {
124            Send::Usual if sms_app => Send::Words,
125            Send::Usual => Send::Off,
126            other => other,
127        }
128    }
129}
130
131/// An app's choice, with its name as last seen.
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct AppChoice {
134    #[serde(default)]
135    pub send: Send,
136    #[serde(default, skip_serializing_if = "String::is_empty")]
137    pub label: String,
138}
139
140/// The phone's choices (`$XDG_CONFIG_HOME/sioul/phone-messages.toml`): never
141/// shared, the phone decides what leaves it.
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Choices {
144    /// The phone's default SMS app, as the listener last saw it.
145    #[serde(default, skip_serializing_if = "String::is_empty")]
146    pub sms_app: String,
147    /// By the app's package; an app not here sends as usual.
148    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149    pub app: BTreeMap<String, AppChoice>,
150    /// The apps whose notifications your computers may not answer ("Answer
151    /// from your computers" switched off; on for every other app).
152    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
153    pub no_answers: BTreeSet<String>,
154}
155
156impl Choices {
157    pub fn default_path() -> PathBuf {
158        crate::config::config_dir().join(CHOICES_FILE)
159    }
160
161    /// As kept; what does not read is no choice at all (each app as usual).
162    pub fn load(path: &Path) -> Choices {
163        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
164    }
165
166    /// Written whole beside, then put in place, yours alone.
167    pub fn save(&self, path: &Path) -> Result<(), String> {
168        let text = toml::to_string(self).map_err(|e| e.to_string())?;
169        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
170        let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
171        let _ = std::fs::remove_file(&fresh);
172        calls::append_private(&fresh, &text)?;
173        std::fs::rename(&fresh, path).map_err(|e| {
174            let _ = std::fs::remove_file(&fresh);
175            format!("{}: {e}", path.display())
176        })
177    }
178
179    /// What an app sends (`sms_app`: Android says it is the phone's SMS app).
180    pub fn send(&self, package: &str, sms_app: bool) -> Send {
181        let chosen = self.app.get(package).map_or(Send::Usual, |c| c.send);
182        chosen.resolved(sms_app || (!self.sms_app.is_empty() && package == self.sms_app))
183    }
184
185    /// Whether your computers may answer an app's notifications (`replies`): on unless switched off.
186    pub fn answers(&self, package: &str) -> bool {
187        !self.no_answers.contains(package)
188    }
189
190    /// "Answer from your computers" switched for an app.
191    pub fn set_answers(&mut self, package: &str, on: bool) {
192        if on {
193            self.no_answers.remove(package);
194        } else {
195            self.no_answers.insert(package.to_string());
196        }
197    }
198
199    /// An app's choice written; "as usual" takes it out.
200    pub fn set(&mut self, package: &str, send: Send, label: &str) {
201        if send == Send::Usual {
202            self.app.remove(package);
203        } else {
204            self.app.insert(package.to_string(), AppChoice { send, label: label.to_string() });
205        }
206    }
207}
208
209// ---------------------------------------------------------------- what Java says besides
210
211/// One message's own, besides `appnotes::Message`: when it was sent (ms, 0
212/// unsaid) and whether it carries a picture.
213#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
214#[serde(default)]
215pub struct ExtraMessage {
216    pub at: i64,
217    pub picture: bool,
218}
219
220/// What a notification offers a reply from your computers, as the phone's
221/// listener found its actions (Replies.java, `choose`): a Reply the phone can
222/// fill and send as the shade does, a Mark as read; neither below Android 12,
223/// whose actions do not say what is needed to refuse the others.
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
225#[serde(default)]
226pub struct Offer {
227    pub reply: bool,
228    pub read: bool,
229}
230
231/// What Java says of a notification besides `appnotes::Posted`, in the same
232/// JSON (AppNotes.java, `describe`), which `Posted` passes over.
233#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
234#[serde(default)]
235pub struct Extra {
236    /// `Notification.visibility`: 1 public, 0 private, -1 secret.
237    pub visibility: i32,
238    /// `Notification.when` (ms); 0 unsaid.
239    pub when: i64,
240    /// When Android posted it (ms).
241    pub posted: i64,
242    /// A picture of its own (`BigPictureStyle`).
243    pub picture: bool,
244    /// Its messages', in `Posted::messages`' order.
245    pub messages: Vec<ExtraMessage>,
246    /// Its Reply and Mark as read, as the listener found them.
247    pub offer: Offer,
248    /// What went through its Reply lately, newest first
249    /// (`Notification.EXTRA_REMOTE_INPUT_HISTORY`, the person's own words):
250    /// read in memory only, to tell a reply from a computer shown there
251    /// (`replies::shows`), never kept.
252    pub history: Vec<String>,
253}
254
255impl Extra {
256    /// From the listener's JSON; nothing said, when it does not read.
257    pub fn read(json: &str) -> Extra {
258        serde_json::from_str(json).unwrap_or_default()
259    }
260}
261
262// ---------------------------------------------------------------- a line
263
264/// A message as a phone's log keeps it and the sharing carries it.
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct Line {
267    /// When it was sent (ms, as the app said; else when the phone heard it).
268    pub at: i64,
269    /// `<at>-<hash>`: the same message, posted again, is the same line.
270    pub id: String,
271    /// The app's package, and its name as the phone shows it.
272    pub app: String,
273    #[serde(default, skip_serializing_if = "String::is_empty")]
274    pub label: String,
275    /// "text" (an SMS app's), "chat", "mail" (a mail app's), "app" (another app's own).
276    pub kind: String,
277    /// The conversation's key (`appnotes::talk_key`); "" none.
278    #[serde(default, skip_serializing_if = "String::is_empty")]
279    pub talk: String,
280    /// The notification it came in, by the phone's own name for it
281    /// (`replies::notice`: a hash of its app and Android's key, which never
282    /// leaves the phone): what a reply from a computer names. "" in a line
283    /// written before 18 October 2026, which no reply can name.
284    #[serde(default, skip_serializing_if = "String::is_empty")]
285    pub notice: String,
286    /// A group's title.
287    #[serde(default, skip_serializing_if = "String::is_empty")]
288    pub title: String,
289    #[serde(default, skip_serializing_if = "is_false")]
290    pub group: bool,
291    /// Who wrote, as the app named them.
292    #[serde(default, skip_serializing_if = "String::is_empty")]
293    pub name: String,
294    /// Their number as Sioul keys it (`phones::key`), when the app gave one.
295    #[serde(default, skip_serializing_if = "String::is_empty")]
296    pub key: String,
297    /// The row the phone judged them by: "safe", "neutral", "restricted",
298    /// "stranger"; "automaton" (a service's text, an app's own), "at-once"
299    /// (an app you let through at once); "" for a code's line.
300    #[serde(default, skip_serializing_if = "String::is_empty")]
301    pub who: String,
302    /// The Always through list holds them (the phone's judgement).
303    #[serde(default, skip_serializing_if = "is_false")]
304    pub always: bool,
305    /// The words, cut at `TEXT_MAX` characters.
306    #[serde(default, skip_serializing_if = "String::is_empty")]
307    pub text: String,
308    /// It carried a picture, which stayed on the phone.
309    #[serde(default, skip_serializing_if = "is_false")]
310    pub picture: bool,
311    /// A code or an approval came: said, never carried.
312    #[serde(default, skip_serializing_if = "is_false")]
313    pub code: bool,
314    /// Its words stayed on the phone: its app sends who and when only.
315    #[serde(default, skip_serializing_if = "is_false")]
316    pub withheld: bool,
317    /// When the phone let its notification through (ms): no device shows it before.
318    #[serde(default)]
319    pub shows: i64,
320    /// Spaces that make the line's length a multiple of `PAD_STEP` bytes (`padded`).
321    #[serde(default, skip_serializing_if = "String::is_empty")]
322    pub pad: String,
323    /// The phone that wrote it: its name in the sharing, its log's file's name. Never written.
324    #[serde(skip)]
325    pub device: String,
326}
327
328fn is_false(value: &bool) -> bool {
329    !*value
330}
331
332/// A line as written: its JSON padded with spaces to a multiple of
333/// `PAD_STEP` bytes, no newline. Two messages of different lengths under
334/// the same step weigh the same once sealed.
335pub fn padded(line: &Line) -> String {
336    let bare = Line { pad: String::new(), device: String::new(), ..line.clone() };
337    let text = serde_json::to_string(&bare).unwrap_or_default();
338    if text.len() % PAD_STEP == 0 {
339        return text;
340    }
341    // `,"pad":""` and the spaces inside it.
342    let room = text.len() + 9;
343    let spaces = (PAD_STEP - room % PAD_STEP) % PAD_STEP;
344    serde_json::to_string(&Line { pad: " ".repeat(spaces), ..bare }).unwrap_or(text)
345}
346
347/// A name on one line, cut: an app's words never break Sioul's.
348pub(crate) fn one_line(text: &str, max: usize) -> String {
349    let joined = text.split_whitespace().collect::<Vec<_>>().join(" ");
350    cut(&joined, max)
351}
352
353/// At most `max` characters, "…" said where it was cut.
354fn cut(text: &str, max: usize) -> String {
355    match text.char_indices().nth(max) {
356        Some((at, _)) => format!("{}…", text[..at].trim_end()),
357        None => text.to_string(),
358    }
359}
360
361/// A message's words as they travel: trimmed, cut at `TEXT_MAX` characters.
362pub fn words_of(text: &str) -> String {
363    cut(text.trim(), TEXT_MAX)
364}
365
366// ---------------------------------------------------------------- what travels
367
368/// A notification as the phone's listener decided it, with what you chose for its app.
369pub struct Notice<'a> {
370    pub posted: &'a Posted,
371    pub extra: &'a Extra,
372    pub kind: &'a Kind,
373    /// Who wrote, as your lists say (`appnotes::decide`'s), none when nobody knows them.
374    pub who: Option<Who>,
375    /// The Always through list holds them.
376    pub listed: bool,
377    pub decision: &'a Decision,
378    /// What its app sends, as chosen (`Choices::send`).
379    pub send: Send,
380    pub now_ms: i64,
381    pub region: Option<&'static Region>,
382    pub words: &'a crate::words::Words,
383}
384
385/// Whether a message's sender is the person themselves, as the conversation names them.
386pub(crate) fn is_user(p: &Posted, sender: &Person) -> bool {
387    p.user.as_ref().is_some_and(|user| {
388        let (key, name) = (user.key.trim(), one_line(&user.name, NAME_MAX));
389        (!key.is_empty() && key == sender.key.trim()) || (!name.is_empty() && name == one_line(&sender.name, NAME_MAX))
390    })
391}
392
393/// A person's number as Sioul keys it: the app's `tel:`, else the phone's
394/// address book's first, else their name when it is a number; "" none.
395fn number_key(person: Option<&Person>, fallback_name: &str, region: Option<&Region>) -> String {
396    let from_uri = person.and_then(|p| p.uri.trim().strip_prefix("tel:")).map(str::to_string);
397    let from_book = person.and_then(|p| p.numbers.iter().find(|n| !n.trim().is_empty()).cloned());
398    let from_name = appnotes::number_in(person.map_or(fallback_name, |p| p.name.as_str()));
399    from_uri.or(from_book).or(from_name).map(|n| phones::key(&n.replace("%2B", "+"), region)).unwrap_or_default()
400}
401
402/// Who wrote in a notification, others than the person: their numbers as
403/// Sioul keys them and their names as lines say them (`messages_of`), its
404/// people's and its title's too. What tells a conversation without a key of
405/// its own that it is still the one a line came from (`replies::check`).
406pub(crate) fn writers(p: &Posted, region: Option<&Region>) -> (Vec<String>, Vec<String>) {
407    let senders: Vec<&Person> = p.messages.iter().filter_map(|m| m.sender.as_ref()).filter(|s| !is_user(p, s)).chain(p.people.iter()).collect();
408    let numbers = senders.iter().map(|s| number_key(Some(s), "", region)).chain(std::iter::once(number_key(p.people.first(), &p.title, region))).filter(|k| !k.is_empty()).collect();
409    let names = senders.iter().map(|s| one_line(&s.name, NAME_MAX)).chain(std::iter::once(one_line(&p.title, NAME_MAX))).filter(|n| !n.is_empty()).collect();
410    (numbers, names)
411}
412
413/// When the newest message of others in a notification was sent (ms), by
414/// its messages' own times only; 0 when they give none: a notification's
415/// own time moves with a silent repost, and is never taken for a message's
416/// (`replies::check`, a reply crossing their newer messages).
417pub(crate) fn newest_timed_from_others(p: &Posted, extra: &Extra) -> i64 {
418    p.messages.iter().enumerate().filter(|(_, m)| m.sender.as_ref().is_some_and(|s| !is_user(p, s))).filter_map(|(i, _)| extra.messages.get(i).map(|e| e.at)).max().unwrap_or(0)
419}
420
421/// A group's title as its lines keep it (one line, `NAME_MAX`); "" for a conversation between two.
422pub(crate) fn title_of(c: &appnotes::Conversation) -> String {
423    if c.group { one_line(&c.title, NAME_MAX) } else { String::new() }
424}
425
426/// Who a code came from, as safe to say: the last sender's name, else the
427/// conversation's, else the title; the app's name when that name may hold
428/// the code itself (four digits in a row that are no phone number, a short
429/// number included), or nothing.
430fn code_sender(p: &Posted, words: &crate::words::Words) -> String {
431    let last = p.messages.iter().rev().find_map(|m| m.sender.as_ref().filter(|s| !is_user(p, s))).map(|s| s.name.as_str());
432    let named = [last.unwrap_or(""), p.people.first().map_or("", |s| s.name.as_str()), p.conversation.as_str(), p.title.as_str()].into_iter().map(|n| one_line(n, NAME_MAX)).find(|n| !n.is_empty()).unwrap_or_default();
433    // Four digits in a row that are no phone number may be the code itself ("G-482913"): never said.
434    let mut run = 0;
435    let coded = named.chars().any(|c| {
436        run = if c.is_ascii_digit() { run + 1 } else { 0 };
437        run >= 4
438    });
439    if named.is_empty() || crate::codes::detect(&words.codes, &named, "").is_some() || (coded && appnotes::number_in(&named).is_none()) {
440        one_line(&p.app, NAME_MAX)
441    } else {
442        named
443    }
444}
445
446/// The lines a notification makes, none when nothing of it may leave the
447/// phone: its app off for your computers, Sioul's own, an ongoing one, a
448/// summary, a notification its app marked secret, a blocked sender's, a
449/// conversation held for good, what is never held (calls, alarms), a missed
450/// call (the calls' part carries calls). A code, an approval or a copy
451/// Android redacted: one line that says a code came, without it. Otherwise
452/// one line per message of someone else's (a conversation's earlier messages
453/// are the same lines again: `carry_into` keeps each once), or one for the
454/// notification; who and when only for an app set so; never older than the
455/// lines are kept.
456pub fn lines_of(n: &Notice) -> Vec<Line> {
457    let p = n.posted;
458    let send = n.send;
459    if matches!(send, Send::Off | Send::Usual) || p.package.is_empty() || p.package == appnotes::OWN || p.ongoing || p.summary || p.full_screen || n.extra.visibility == SECRET {
460        return Vec::new();
461    }
462    if n.who == Some(Who::Blocked) || n.decision.why == Why::Never {
463        return Vec::new();
464    }
465    let heard = [n.extra.when, n.extra.posted].into_iter().find(|t| *t > 0).unwrap_or(n.now_ms);
466    let shows = n.decision.until.map_or(n.now_ms, |until| until.saturating_mul(1000)).max(heard.min(n.now_ms));
467    let sms = p.sms_app || matches!(n.kind, Kind::People(talk) if talk.sms);
468    let label = one_line(&p.app, NAME_MAX);
469    let notice = if p.key.is_empty() { String::new() } else { crate::replies::notice(&p.package, &p.key) };
470    let base = Line { app: p.package.clone(), label: label.clone(), notice, shows, ..Line::default() };
471    let mut lines = match n.kind {
472        Kind::Untouched => return Vec::new(),
473        Kind::Code => {
474            let name = code_sender(p, n.words);
475            vec![Line { at: heard, kind: (if sms { "text" } else { "app" }).into(), name, code: true, shows: heard.min(n.now_ms), ..base }]
476        }
477        Kind::People(talk) => {
478            if talk.via == Channel::Calls {
479                return Vec::new();
480            }
481            let kind = if sms { "text" } else if talk.via == Channel::Mail { "mail" } else { "chat" };
482            // A text from a sender named in letters that no address book knows: a service's.
483            let service = sms && n.who.is_none() && matches!(talk.sender, Sender::Named { .. });
484            let who = if service { "automaton".to_string() } else { n.who.unwrap_or(Who::Stranger).id().to_string() };
485            let (talk_key, title, group) = talk.conversation.as_ref().map_or((String::new(), String::new(), false), |c| (c.key.clone(), title_of(c), c.group));
486            let line = Line { kind: kind.into(), talk: talk_key, title, group, who, always: n.listed && !group, ..base };
487            messages_of(n, line, heard)
488        }
489        Kind::Automaton { .. } | Kind::AtOnce => {
490            let who = if matches!(n.kind, Kind::AtOnce) { "at-once" } else { "automaton" };
491            let line = Line { kind: (if sms { "text" } else { "app" }).into(), who: who.into(), ..base };
492            messages_of(n, line, heard)
493        }
494    };
495    for line in &mut lines {
496        if send == Send::Who && !line.code {
497            line.text.clear();
498            line.picture = false;
499            line.withheld = true;
500        }
501        line.id = format!("{}-{}", line.at, appnotes::talk_key(&line.app, &format!("{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}", line.talk, line.name, line.key, line.text, line.code)));
502    }
503    lines.retain(|l| l.at > 0 && n.now_ms - l.at <= KEPT_DAYS * DAY_MS);
504    lines
505}
506
507/// A conversation's messages from others, each a line made from `line`;
508/// without their own times (an older Java), its last one only, at `heard`.
509/// No messages: the notification itself, its title the sender.
510fn messages_of(n: &Notice, line: Line, heard: i64) -> Vec<Line> {
511    let p = n.posted;
512    let region = n.region;
513    let from_others: Vec<(usize, &appnotes::Message, &Person)> = p.messages.iter().enumerate().filter_map(|(i, m)| m.sender.as_ref().filter(|s| !is_user(p, s)).map(|s| (i, m, s))).collect();
514    if !from_others.is_empty() {
515        let timed = from_others.iter().all(|(i, _, _)| n.extra.messages.get(*i).is_some_and(|e| e.at > 0));
516        let chosen: Vec<&(usize, &appnotes::Message, &Person)> = if timed { from_others.iter().collect() } else { from_others.last().into_iter().collect() };
517        // A conversation between two: its number is the conversation's, for Text back.
518        let theirs = (!line.group).then(|| p.people.iter().find(|s| s.uri.trim().starts_with("tel:"))).flatten();
519        return chosen
520            .into_iter()
521            .map(|(i, message, sender)| {
522                let extra = n.extra.messages.get(*i).cloned().unwrap_or_default();
523                let key = Some(number_key(Some(sender), "", region)).filter(|k| !k.is_empty()).unwrap_or_else(|| theirs.map(|t| number_key(Some(t), "", region)).unwrap_or_default());
524                Line { at: if timed { extra.at } else { heard }, name: one_line(&sender.name, NAME_MAX), key, text: words_of(&message.text), picture: extra.picture, ..line.clone() }
525            })
526            .collect();
527    }
528    let person = p.people.first();
529    let name = [person.map_or("", |s| s.name.as_str()), p.title.as_str()].into_iter().map(|t| one_line(t, NAME_MAX)).find(|t| !t.is_empty()).unwrap_or_default();
530    let words = [p.big.as_str(), p.text.as_str()].into_iter().find(|t| !t.trim().is_empty()).map(str::to_string).unwrap_or_else(|| p.lines.join("\n"));
531    vec![Line { at: heard, key: number_key(person, &name, region), name, text: words_of(&words), picture: n.extra.picture, ..line }]
532}
533
534// ---------------------------------------------------------------- the logs
535
536/// The lines of one file, oldest first, each message once by its id, at the
537/// earliest time it shows (written again when its phone let it through
538/// earlier: `carry_into`); a line that does not read (half written) is passed over.
539pub fn read_file(path: &Path) -> Vec<Line> {
540    let mut lines: Vec<Line> = Vec::new();
541    let mut place: BTreeMap<String, usize> = BTreeMap::new();
542    for line in calls::read_lines::<Line>(path).into_iter().filter(|l| l.at > 0 && !l.id.is_empty()) {
543        match place.get(&line.id) {
544            Some(&i) => lines[i].shows = lines[i].shows.min(line.shows),
545            None => {
546                place.insert(line.id.clone(), lines.len());
547                lines.push(line);
548            }
549        }
550    }
551    lines.sort_by_key(|l| l.at);
552    lines
553}
554
555/// A message its phone lets through this much earlier than it first said
556/// is written again with that time (`carry_into`).
557pub const EARLIER_MS: i64 = 60_000;
558
559/// Every phone's log under `root` (this folder), each line with its phone
560/// (`device`), oldest first: never a blocked sender's, never one kept past
561/// `KEPT_DAYS` before `now_ms` (a phone that stopped sharing never takes its
562/// own out).
563pub fn read_logs(root: &Path, now_ms: i64) -> Vec<Line> {
564    let mut lines: Vec<Line> = calls::device_files(&root.join(LOG))
565        .into_iter()
566        .flat_map(|(device, path)| read_file(&path).into_iter().map(move |l| Line { device: device.clone(), ..l }))
567        .filter(|l| l.who != "blocked" && now_ms - l.at <= KEPT_DAYS * DAY_MS)
568        .collect();
569    lines.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
570    lines
571}
572
573/// A phone's new lines added to its own log (`own`), under its lock, read
574/// again there: those not in it yet (by id), and those its phone now lets
575/// through more than `EARLIER_MS` earlier than it first said (a hold that
576/// ended early: a pause ended, the hours changed, a conversation answered
577/// from a computer), written again so that computers show them then; none
578/// past `KEPT_DAYS` (taken out already, it would come back), each padded.
579/// Two processes adding at once never write a line twice. Returns how many
580/// were written.
581pub fn carry_into(own: &Path, lines: &[Line], now_ms: i64) -> Result<usize, String> {
582    let fresh: Vec<&Line> = lines.iter().filter(|l| l.at > 0 && !l.id.is_empty() && now_ms - l.at <= KEPT_DAYS * DAY_MS).collect();
583    if fresh.is_empty() {
584        return Ok(0);
585    }
586    crate::filelock::with_lock(own, || {
587        let mut known: BTreeMap<String, i64> = read_file(own).into_iter().map(|l| (l.id, l.shows)).collect();
588        let mut text = String::new();
589        for line in fresh {
590            if known.get(&line.id).is_none_or(|shows| line.shows + EARLIER_MS < *shows) {
591                known.insert(line.id.clone(), line.shows);
592                text.push_str(&padded(line));
593                text.push('\n');
594            }
595        }
596        let written = text.lines().count();
597        if written > 0 {
598            calls::append_private(own, &text)?;
599        }
600        Ok(written)
601    })
602}
603
604/// A line older than `days` before `now_ms`, by its `at`; a line that does not read is kept.
605fn older_than(days: i64, now_ms: i64) -> impl Fn(&str) -> bool {
606    move |line: &str| serde_json::from_str::<serde_json::Value>(line).ok().and_then(|v| v["at"].as_i64()).is_some_and(|at| now_ms - at > days * DAY_MS)
607}
608
609/// This device's own lines kept past `KEPT_DAYS` taken out of its log, of
610/// its seen log and of its live lines (`replies::LIVE`) under `root`: the
611/// sharing then takes them out on every device. Returns how many went.
612pub fn trim_own(root: &Path, device: &str, now_ms: i64) -> Result<usize, String> {
613    let old = older_than(KEPT_DAYS, now_ms);
614    let mut gone = 0;
615    for folder in [LOG, SEEN_LOG, crate::replies::LIVE] {
616        gone += calls::rewrite_lines(&calls::own_file(root, folder, device), &old)?;
617    }
618    Ok(gone)
619}
620
621/// Another device's lines kept `LATE_DAYS` past their week taken out here
622/// (`here`: this device): a phone gone for good never takes its own out.
623/// While it shares, it took them out before (`trim_own`). Returns how many went.
624pub fn trim_others(root: &Path, here: &str, now_ms: i64) -> Result<usize, String> {
625    let old = older_than(KEPT_DAYS + LATE_DAYS, now_ms);
626    let mut gone = 0;
627    for folder in [LOG, SEEN_LOG, crate::replies::LIVE] {
628        for (device, path) in calls::device_files(&root.join(folder)) {
629            if device != calls::file_safe(here) {
630                gone += calls::rewrite_lines(&path, &old)?;
631            }
632        }
633    }
634    Ok(gone)
635}
636
637// ---------------------------------------------------------------- when a computer shows one
638
639/// Whether a line's sender may reach you at `now` by this device's matrix
640/// (`attention`): a code's line at any time; a service's text or an app's
641/// own by the automatons' row (or At once's), shown at its times, gathered
642/// included; someone's message by their Messages row (Mail's for a mail
643/// app's), a group's by the groups' row, Always through's when the list
644/// holds them, at once. The app's and the conversation's own rows, set on
645/// the phone, are read over them. A row that lets them through at no time
646/// shows them in work and admin time, as the calls' list does: no message is
647/// never listed. `who`: as this device's lists judge their number now, else
648/// as the phone judged them. `persons`: the sender's own rows (decision 16;
649/// `porch::Senders::person_rows` of their number), read after the
650/// conversation's and before the app's, as on the phone.
651pub fn reaches_now(attention: &Attention, line: &Line, who: Option<Who>, always: bool, persons: &[String], now: &attention::Now) -> bool {
652    if line.code {
653        return true;
654    }
655    let sources = attention::source_rows_with(&line.app, &line.talk, persons);
656    if matches!(line.who.as_str(), "automaton" | "at-once") {
657        let kind = if line.who == "at-once" { attention::Kind::AppAtOnce } else { attention::Kind::AppAutomatons };
658        return matches!(attention.decide(&Event::own(kind).from_sources(sources), now).level, Level::Now | Level::Quiet | Level::Gathered);
659    }
660    let who = who.or_else(|| Who::read(&line.who)).unwrap_or(Who::Stranger);
661    if who == Who::Blocked {
662        return false;
663    }
664    let via = if line.kind == "mail" { Channel::Mail } else { Channel::Messages };
665    let always = always && !line.group;
666    if attention.decide(&Event::of(Source::Message { via, who, group: line.group, always }).from_sources(sources.clone()), now).level == Level::Now {
667        return true;
668    }
669    let person = if line.group { attention::Person::Groups } else { attention::Person::of(who) };
670    let row = Row::People(via, person);
671    let never = !always && !attention.has_sources(&sources) && Column::TIMES.iter().all(|c| attention.cell(row, *c) != Level::Now);
672    never && !now.dnd && now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin))
673}
674
675// ---------------------------------------------------------------- the Porch's lines
676
677/// One message of a Porch's line.
678#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
679pub struct Said {
680    /// "09:41".
681    pub time: String,
682    /// Who wrote it, in a group; "" otherwise.
683    pub from: String,
684    /// Its words; "a picture"; "" when they stayed on the phone.
685    pub text: String,
686}
687
688/// A line of the Porch's "From your phone": a conversation, in one part of
689/// one day, on one phone; or a code that came.
690#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
691pub struct PorchLine {
692    /// The messages it says, to mark them seen.
693    pub ids: Vec<String>,
694    /// "While you slept, Dr Martin's office wrote:"; "A code came from your
695    /// bank at 09:12. It stays on your phone."
696    pub head: String,
697    pub said: Vec<Said>,
698    /// "Their words stay on your phone: WhatsApp sends who and when only."; "" otherwise.
699    pub note: String,
700    /// "01 99 00 12 34"; "" when no number came with it.
701    pub number: String,
702    /// What Text back and Call back dial ("+33199001234"); "" none.
703    pub dial: String,
704    /// In your address books: no "Add to contacts".
705    pub known: bool,
706    /// A text from a whole number, between two: Text back and Call back offered.
707    pub texted: bool,
708    pub code: bool,
709    /// Who wrote, in words (your address books' name, the app's, or a
710    /// group's title), for a reply's box: "Reply to Alice on Signal…".
711    pub who: String,
712    /// What it offers a reply from this computer (`replies::answer_of`,
713    /// filled where the computer knows its phone's word); none: as before.
714    #[serde(skip_serializing_if = "Option::is_none")]
715    pub answer: Option<crate::replies::Answer>,
716}
717
718/// What the Porch's lines need besides the logs.
719pub struct Lister<'a> {
720    pub now: &'a Zoned,
721    pub tr: &'a Translator,
722    pub region: Option<&'static Region>,
723    /// The name your address books give a number's key; none: not in them.
724    pub name_of: &'a dyn Fn(&str) -> Option<String>,
725    /// Whether this line's sender may reach you now (`reaches_now`).
726    pub shows: &'a dyn Fn(&Line) -> bool,
727    /// The phone a line came from, as this device says it: "" for this
728    /// device's own, "your phone", "your phone (GS290)" (`calls::phone_words`).
729    pub phone: &'a dyn Fn(&str) -> String,
730    /// The part of the day a moment (ms) fell in: a column of the matrix ("work", "sleep"…); "" unknown.
731    pub column: &'a dyn Fn(i64) -> String,
732}
733
734/// "while you slept": the part of the day, in a few words (the calls' words).
735fn context(tr: &Translator, column: &str) -> String {
736    match column {
737        "work" | "admin" | "leisure" | "meals" | "sleep" | "pause" | "free" | "slot" | "dnd" => calls::context(tr, column),
738        _ => tr.text("phonemsgs-context-any", None),
739    }
740}
741
742/// Who wrote, in words: your address books' name for their number, else
743/// the app's name for them (a number written as you read numbers), else "a
744/// number not in your contacts", else "someone".
745fn who_words(line: &Line, l: &Lister) -> (String, bool) {
746    if let Some(name) = (!line.key.is_empty()).then(|| (l.name_of)(&line.key)).flatten() {
747        return (name, true);
748    }
749    let name = line.name.trim();
750    if !name.is_empty() && appnotes::number_in(name).is_none() {
751        return (name.to_string(), false);
752    }
753    if !line.key.is_empty() || !name.is_empty() {
754        return (l.tr.text("calls-who-stranger", None), false);
755    }
756    (l.tr.text("phonemsgs-who-someone", None), false)
757}
758
759/// A message's words as a Porch's line says them.
760fn said_text(line: &Line, tr: &Translator) -> String {
761    if line.withheld {
762        return String::new();
763    }
764    match (line.text.trim().is_empty(), line.picture) {
765        (true, true) => tr.text("phonemsgs-picture", None),
766        (false, true) => {
767            let mut args = crate::i18n::args();
768            args.set("text", line.text.clone());
769            tr.text("phonemsgs-with-picture", Some(&args))
770        }
771        (true, false) => tr.text("phonemsgs-no-words", None),
772        (false, false) => line.text.clone(),
773    }
774}
775
776/// The Porch's lines, oldest first: the messages of the last week not seen
777/// on any device, once the phone let them through and while their sender
778/// may reach you; never a blocked sender's. One line per phone,
779/// conversation (or sender), part of the day and day; a code its own line.
780/// `all` are every phone's (`read_logs`), oldest first.
781pub fn lines(all: &[Line], seen: &calls::Seen, l: &Lister) -> Vec<PorchLine> {
782    let now_ms = l.now.timestamp().as_millisecond();
783    let shown: Vec<&Line> = all.iter().filter(|m| m.who != "blocked" && !seen.seen.contains(&m.id) && now_ms - m.at <= KEPT_DAYS * DAY_MS && m.at <= now_ms + 60_000 && now_ms >= m.shows && (l.shows)(m)).collect();
784    type Group<'a> = (String, String, jiff::civil::Date, Vec<&'a Line>);
785    let mut groups: Vec<Group> = Vec::new();
786    for m in shown {
787        let column = (l.column)(m.at);
788        let date = calls::local(m.at, l.now).date();
789        let who = if m.code {
790            format!("code\u{1f}{}", m.id)
791        } else if !m.talk.is_empty() {
792            m.talk.clone()
793        } else if !m.key.is_empty() {
794            m.key.clone()
795        } else {
796            format!("{}\u{1f}{}", m.app, m.name)
797        };
798        let group_key = format!("{}\u{1f}{who}", m.device);
799        match groups.iter_mut().find(|(k, c, d, _)| *k == group_key && *c == column && *d == date) {
800            Some((_, _, _, group)) => group.push(m),
801            None => groups.push((group_key, column, date, vec![m])),
802        }
803    }
804    groups.into_iter().map(|(_, column, date, group)| porch_line(&group, &column, date, l)).collect()
805}
806
807fn porch_line(group: &[&Line], column: &str, date: jiff::civil::Date, l: &Lister) -> PorchLine {
808    let tr = l.tr;
809    let first = group[0];
810    let time = |m: &Line| calls::local(m.at, l.now).strftime("%H:%M").to_string();
811    let today = l.now.date();
812    let ids: Vec<String> = group.iter().map(|m| m.id.clone()).collect();
813    let keyed = group.iter().find(|m| !m.key.is_empty() && !m.group).map(|m| m.key.clone()).unwrap_or_default();
814    let (who, known) = who_words(first, l);
815    // Which phone, only among several: the section says "From your phone" already.
816    let phone = Some((l.phone)(&first.device)).filter(|p| *p != tr.text("calls-phone-yours", None) && *p != tr.text("calls-phone-other", None)).unwrap_or_default();
817    if first.code {
818        let mut args = crate::i18n::args();
819        args.set("who", who);
820        args.set("time", time(first));
821        let head = if date == today {
822            tr.text("phonemsgs-code", Some(&args))
823        } else {
824            args.set("day", calls::day_words(tr, date, today));
825            tr.text("phonemsgs-code-day", Some(&args))
826        };
827        return PorchLine { ids, head: calls::capitalized(&head), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known, texted: false, code: true, who: String::new(), answer: None };
828    }
829    let mut context_said = context(tr, column);
830    if date != today {
831        let mut args = crate::i18n::args();
832        args.set("day", calls::day_words(tr, date, today));
833        args.set("context", context_said);
834        context_said = tr.text("calls-context-day", Some(&args));
835    }
836    // An SMS app needs no naming; a chat's or a mail app's does.
837    let app = if first.kind == "text" { String::new() } else { first.label.clone() };
838    let named = if first.group && !first.title.is_empty() { first.title.clone() } else { who.clone() };
839    let mut args = crate::i18n::args();
840    args.set("context", context_said);
841    args.set("who", who);
842    args.set("group", first.title.clone());
843    args.set("app", app.clone());
844    args.set("phone", phone.clone());
845    let shape = if first.group { "-group" } else { "" };
846    let with_app = if app.is_empty() { "" } else { "-app" };
847    let on = if phone.is_empty() { "" } else { "-on" };
848    let head = calls::capitalized(&tr.text(&format!("phonemsgs-head{shape}{with_app}{on}"), Some(&args)));
849    let said = group
850        .iter()
851        .map(|m| Said {
852            time: time(m),
853            from: if m.group { (!m.key.is_empty()).then(|| (l.name_of)(&m.key)).flatten().unwrap_or_else(|| m.name.clone()) } else { String::new() },
854            text: said_text(m, tr),
855        })
856        .collect();
857    let note = if group.iter().any(|m| m.withheld) {
858        let mut args = crate::i18n::args();
859        args.set("app", if first.label.is_empty() { first.app.clone() } else { first.label.clone() });
860        tr.text("phonemsgs-withheld", Some(&args))
861    } else {
862        String::new()
863    };
864    let texted = first.kind == "text" && !first.group && first.who != "automaton" && phones::is_whole(&keyed);
865    PorchLine {
866        ids,
867        head,
868        said,
869        note,
870        number: if keyed.is_empty() { String::new() } else { calls::shown_number(&keyed, l.region) },
871        dial: if phones::is_whole(&keyed) { keyed.clone() } else { String::new() },
872        known: known || (!keyed.is_empty() && (l.name_of)(&keyed).is_some()),
873        texted,
874        code: false,
875        who: named,
876        answer: None,
877    }
878}
879
880/// A person's messages of the week (`keys`, their numbers as Sioul keys
881/// them), newest first, from every phone: a sentence each, for their sheet,
882/// never a count; codes said without them, words withheld said so. A
883/// blocked sender's never come: they never leave the phone.
884pub fn history(all: &[Line], keys: &BTreeSet<String>, l: &Lister) -> Vec<String> {
885    let tr = l.tr;
886    let now_ms = l.now.timestamp().as_millisecond();
887    all.iter()
888        .rev()
889        .filter(|m| !m.key.is_empty() && keys.contains(&m.key) && m.who != "blocked" && now_ms - m.at <= KEPT_DAYS * DAY_MS && m.at <= now_ms + 60_000)
890        .map(|m| {
891            let at = calls::local(m.at, l.now);
892            let mut args = crate::i18n::args();
893            args.set("day", calls::day_words(tr, at.date(), l.now.date()));
894            args.set("time", at.strftime("%H:%M").to_string());
895            let when = tr.text("calls-history-when", Some(&args));
896            let mut args = crate::i18n::args();
897            args.set("when", when);
898            args.set("app", if m.label.is_empty() { m.app.clone() } else { m.label.clone() });
899            args.set("text", said_text(m, tr));
900            let id = if m.code {
901                "phonemsgs-history-code"
902            } else if m.withheld {
903                "phonemsgs-history-withheld"
904            } else if m.kind == "text" {
905                "phonemsgs-history-line"
906            } else {
907                "phonemsgs-history-app"
908            };
909            calls::capitalized(&tr.text(id, Some(&args)))
910        })
911        .collect()
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use crate::appnotes::{Choices as AppChoices, Message};
918
919    /// Thursday 8 October 2026, 10:00 in Paris.
920    const NOW: i64 = 1_791_446_400_000;
921
922    fn fr() -> Option<&'static Region> {
923        phones::region_named("FR")
924    }
925
926    // French and English named, as the owner's configuration does: CI runs
927    // with no locale, where a configuration without [words] reads English only.
928    fn words() -> std::sync::Arc<crate::words::Words> {
929        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
930        crate::words::Words::of(&config)
931    }
932
933    fn person(name: &str, uri: &str) -> Person {
934        Person { name: name.into(), key: uri.into(), uri: uri.into(), ..Person::default() }
935    }
936
937    /// The phone's SMS app's notification of a conversation: its messages,
938    /// each sent a minute apart, the first ten minutes before `NOW`.
939    fn texted(from: &Person, texts: &[&str]) -> (Posted, Extra) {
940        let posted = Posted {
941            key: "0|foundation.e.message|1|null|10123".into(),
942            package: "foundation.e.message".into(),
943            app: "Message".into(),
944            category: "msg".into(),
945            template: "android.app.Notification$MessagingStyle".into(),
946            title: from.name.clone(),
947            text: texts.last().map_or(String::new(), |t| t.to_string()),
948            messages: texts.iter().map(|t| Message { sender: Some(from.clone()), text: t.to_string() }).collect(),
949            people: vec![Person { uri: from.uri.clone(), ..Person::default() }],
950            sms_app: true,
951            ..Posted::default()
952        };
953        let count = texts.len() as i64;
954        let extra = Extra { visibility: 0, when: NOW - 60_000, posted: NOW - 59_000, picture: false, messages: (0..count).map(|i| ExtraMessage { at: NOW - 600_000 + i * 60_000, picture: false }).collect(), ..Extra::default() };
955        (posted, extra)
956    }
957
958    fn decided(posted: &Posted, extra: &Extra, who: Option<Who>, decision: Decision, send: Send) -> Vec<Line> {
959        let words = words();
960        let kind = appnotes::classify(posted, &AppChoices::default(), &words);
961        lines_of(&Notice { posted, extra, kind: &kind, who, listed: false, decision: &decision, send, now_ms: NOW, region: fr(), words: &words })
962    }
963
964    fn now_through() -> Decision {
965        Decision::through(Why::Allowed)
966    }
967
968    #[test]
969    fn a_text_makes_a_line_per_message_with_its_number_and_words() {
970        let martin = person("Cabinet du Dr Martin", "tel:+33199001234");
971        let (posted, extra) = texted(&martin, &["Bonjour,", "Votre rendez-vous est déplacé à jeudi 10 h 30."]);
972        let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words);
973        assert_eq!(lines.len(), 2);
974        let last = &lines[1];
975        assert_eq!((last.kind.as_str(), last.name.as_str(), last.key.as_str(), last.who.as_str()), ("text", "Cabinet du Dr Martin", "+33199001234", "neutral"));
976        assert_eq!(last.text, "Votre rendez-vous est déplacé à jeudi 10 h 30.");
977        assert_eq!((last.at, last.shows), (NOW - 540_000, NOW));
978        // Its notification by the phone's own name for it, never Android's key.
979        assert_eq!(last.notice, crate::replies::notice("foundation.e.message", "0|foundation.e.message|1|null|10123"));
980        assert!(!serde_json::to_string(last).unwrap().contains("null|10123"), "Android's key stays on the phone");
981        assert!(last.id.starts_with(&format!("{}-", NOW - 540_000)));
982        // Posted again with a new message: the same lines again, and the new one.
983        let (again, extra) = texted(&martin, &["Bonjour,", "Votre rendez-vous est déplacé à jeudi 10 h 30.", "Merci de confirmer."]);
984        let more = decided(&again, &extra, Some(Who::Neutral), now_through(), Send::Words);
985        let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-again-{}", std::process::id()));
986        let _ = std::fs::remove_dir_all(&dir);
987        let own = calls::own_file(&dir, LOG, "phone");
988        assert_eq!(carry_into(&own, &lines, NOW).unwrap(), 2);
989        assert_eq!(carry_into(&own, &more, NOW).unwrap(), 1, "only the new message");
990        assert_eq!(read_file(&own).len(), 3);
991        let _ = std::fs::remove_dir_all(&dir);
992    }
993
994    /// Held until 13:00, then let through at 11:00 (the pause ended, the
995    /// conversation answered from a computer): the line is written again with
996    /// 11:00, read once, at 11:00, by computers; never again for a time later
997    /// than the earliest, nor for a minute's difference.
998    #[test]
999    fn a_message_let_through_earlier_shows_earlier_on_computers() {
1000        let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-earlier-{}", std::process::id()));
1001        let _ = std::fs::remove_dir_all(&dir);
1002        let own = calls::own_file(&dir, LOG, "phone");
1003        let hour = 3_600_000;
1004        let line = |shows: i64| Line { at: NOW, id: format!("{NOW}-a"), app: "foundation.e.message".into(), kind: "text".into(), who: "neutral".into(), text: "Bonjour".into(), shows, ..Line::default() };
1005        assert_eq!(carry_into(&own, &[line(NOW + 3 * hour)], NOW).unwrap(), 1);
1006        assert_eq!(carry_into(&own, &[line(NOW + 3 * hour)], NOW).unwrap(), 0, "the same");
1007        assert_eq!(carry_into(&own, &[line(NOW + 3 * hour - 30_000)], NOW).unwrap(), 0, "half a minute earlier: the same");
1008        assert_eq!(carry_into(&own, &[line(NOW + hour)], NOW + hour).unwrap(), 1, "let through two hours earlier");
1009        assert_eq!(carry_into(&own, &[line(NOW + 2 * hour)], NOW + 2 * hour).unwrap(), 0, "later than the earliest");
1010        let read = read_file(&own);
1011        assert_eq!((read.len(), read[0].shows), (1, NOW + hour));
1012        assert_eq!(read_logs(&dir, NOW + hour).len(), 1, "once on a computer");
1013        let _ = std::fs::remove_dir_all(&dir);
1014    }
1015
1016    /// What never makes a line, or never carries its words: a code, a copy
1017    /// Android redacted, a notification marked secret, a blocked sender's,
1018    /// a conversation held for good, an app off for your computers, Sioul's
1019    /// own, an ongoing one; a picture's line says "a picture", never more.
1020    #[test]
1021    fn what_must_stay_on_the_phone_never_travels() {
1022        let bank = person("Ma Banque", "tel:+33465710042");
1023        // A code: one line, said, without it.
1024        let (code, extra) = texted(&bank, &["Votre code de validation est 482913. Ne le communiquez à personne."]);
1025        let lines = decided(&code, &extra, Some(Who::Neutral), Decision::through(Why::Code), Send::Words);
1026        assert_eq!(lines.len(), 1);
1027        assert!(lines[0].code && lines[0].text.is_empty() && lines[0].name == "Ma Banque", "{lines:?}");
1028        assert!(!padded(&lines[0]).contains("482913"));
1029        // A code from a short number, or a title that may be the code: the app's name said instead.
1030        let short = Person { name: "38015".into(), ..Person::default() };
1031        let (code, extra) = texted(&short, &["G-482913 est votre code de vérification."]);
1032        let lines = decided(&code, &extra, None, Decision::through(Why::Code), Send::Words);
1033        assert_eq!(lines.iter().map(|l| (l.code, l.name.as_str())).collect::<Vec<_>>(), [(true, "Message")]);
1034        // A copy Android redacted: a code too, its words never carried.
1035        let (mut redacted, extra) = texted(&bank, &["Sensitive notification content hidden"]);
1036        redacted.redacted = true;
1037        let lines = decided(&redacted, &extra, Some(Who::Neutral), Decision::through(Why::Code), Send::Words);
1038        assert!(lines.iter().all(|l| l.code && l.text.is_empty()), "{lines:?}");
1039        // Marked secret by its app: nothing at all.
1040        let martin = person("Cabinet du Dr Martin", "tel:+33199001234");
1041        let (posted, mut extra) = texted(&martin, &["Résultats disponibles."]);
1042        extra.visibility = SECRET;
1043        assert!(decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words).is_empty());
1044        // A blocked sender, a conversation held for good: nothing.
1045        let (posted, extra) = texted(&martin, &["Encore moi."]);
1046        assert!(decided(&posted, &extra, Some(Who::Blocked), Decision::held(NOW / 1000 + appnotes::FOR_GOOD, Why::Never), Send::Words).is_empty());
1047        assert!(decided(&posted, &extra, Some(Who::Neutral), Decision::held(NOW / 1000 + appnotes::FOR_GOOD, Why::Never), Send::Words).is_empty());
1048        // The app off, or as usual and not the SMS app: nothing.
1049        assert!(decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Off).is_empty());
1050        assert!(decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Usual).is_empty(), "lines_of takes a resolved choice");
1051        // Sioul's own, an ongoing one: nothing.
1052        let own = Posted { package: appnotes::OWN.into(), ..posted.clone() };
1053        assert!(decided(&own, &extra, Some(Who::Neutral), now_through(), Send::Words).is_empty());
1054        let ongoing = Posted { ongoing: true, ..posted.clone() };
1055        assert!(decided(&ongoing, &extra, Some(Who::Neutral), now_through(), Send::Words).is_empty());
1056        // A picture: said, never carried.
1057        let (posted, mut extra) = texted(&martin, &[""]);
1058        extra.messages[0].picture = true;
1059        let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words);
1060        assert_eq!(lines.len(), 1);
1061        assert!(lines[0].picture && lines[0].text.is_empty());
1062        assert!(!padded(&lines[0]).contains("content://"));
1063        // Who and when only: the words stay.
1064        let (posted, extra) = texted(&martin, &["Résultats disponibles."]);
1065        let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Who);
1066        assert!(lines.len() == 1 && lines[0].withheld && lines[0].text.is_empty() && lines[0].name == "Cabinet du Dr Martin");
1067    }
1068
1069    #[test]
1070    fn a_service_s_text_travels_as_an_automaton_s_held_as_the_phone_holds_it() {
1071        let ameli = Person { name: "AMELI".into(), ..Person::default() };
1072        let (posted, extra) = texted(&ameli, &["Votre attestation est disponible sur votre compte."]);
1073        let at_13 = NOW / 1000 + 3 * 3600;
1074        let lines = decided(&posted, &extra, None, Decision::held(at_13, Why::Gathered), Send::Words);
1075        assert_eq!(lines.len(), 1);
1076        assert_eq!((lines[0].who.as_str(), lines[0].name.as_str(), lines[0].shows), ("automaton", "AMELI", at_13 * 1000));
1077        assert!(lines[0].key.is_empty(), "no number to text back");
1078    }
1079
1080    #[test]
1081    fn long_words_are_cut_and_every_line_weighs_steps_of_256_bytes() {
1082        let martin = person("Cabinet du Dr Martin", "tel:+33199001234");
1083        let long = "é".repeat(1500);
1084        let (posted, extra) = texted(&martin, &[long.as_str()]);
1085        let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words);
1086        assert_eq!(lines[0].text.chars().count(), TEXT_MAX + 1, "cut, with its ellipsis");
1087        for text in ["", "Oui", "Votre rendez-vous est déplacé à jeudi 10 h 30.", &"x".repeat(240), &"x".repeat(247), &"x".repeat(1000)] {
1088            let line = Line { at: NOW, id: format!("{NOW}-0123456789abcdef"), app: "foundation.e.message".into(), kind: "text".into(), text: text.into(), shows: NOW, ..Line::default() };
1089            let written = padded(&line);
1090            assert_eq!(written.len() % PAD_STEP, 0, "{} bytes for {} characters", written.len(), text.len());
1091            let back: Line = serde_json::from_str(&written).unwrap();
1092            assert_eq!(Line { pad: String::new(), ..back }, line);
1093        }
1094        // Two messages of 3 and 40 characters weigh the same.
1095        let weigh = |t: &str| padded(&Line { at: NOW, id: "x".into(), app: "a".into(), kind: "text".into(), text: t.into(), ..Line::default() }).len();
1096        assert_eq!(weigh("Oui"), weigh("Je serai là vers dix-huit heures, à demain"));
1097    }
1098
1099    #[test]
1100    fn a_phone_trims_its_own_after_a_week_and_any_device_another_s_after_ten_days() {
1101        let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-trim-{}", std::process::id()));
1102        let _ = std::fs::remove_dir_all(&dir);
1103        let line = |at: i64| Line { at, id: format!("{at}-a"), app: "foundation.e.message".into(), kind: "text".into(), who: "neutral".into(), text: "Bonjour".into(), shows: at, ..Line::default() };
1104        carry_into(&calls::own_file(&dir, LOG, "phone"), &[line(NOW - 3_600_000)], NOW).unwrap();
1105        calls::mark_seen(&dir, "desk", &[format!("{}-a", NOW - 3_600_000)], NOW).unwrap();
1106        assert_eq!(read_logs(&dir, NOW).len(), 1);
1107        let week = NOW + KEPT_DAYS * DAY_MS;
1108        assert!(read_logs(&dir, week).is_empty(), "past its week, read nowhere");
1109        assert_eq!(trim_others(&dir, "desk", week).unwrap(), 0, "the phone's own week: another device waits");
1110        assert_eq!(trim_own(&dir, "phone", week).unwrap(), 1);
1111        carry_into(&calls::own_file(&dir, LOG, "phone"), &[line(NOW)], NOW).unwrap();
1112        let ten = NOW + (KEPT_DAYS + LATE_DAYS) * DAY_MS + 1;
1113        assert_eq!(trim_others(&dir, "desk", ten).unwrap(), 1, "the phone gone: the desk takes its line out");
1114        assert_eq!(trim_own(&dir, "desk", ten).unwrap(), 1, "the desk's own seen line too");
1115        let _ = std::fs::remove_dir_all(&dir);
1116    }
1117
1118    fn lister_at<'a>(now: &'a Zoned, tr: &'a Translator, shows: &'a dyn Fn(&Line) -> bool, column: &'a dyn Fn(i64) -> String) -> Lister<'a> {
1119        Lister { now, tr, region: fr(), name_of: &|key: &str| (key == "+33199001234").then(|| "Dr Martin".to_string()), shows, phone: &|_: &str| String::new(), column }
1120    }
1121
1122    #[test]
1123    fn the_porch_groups_a_conversation_by_part_of_the_day_and_says_codes_and_pictures() {
1124        let tr = Translator::new("en");
1125        let now = jiff::Timestamp::from_millisecond(NOW).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap());
1126        let m = |at: i64, text: &str| Line { at, id: format!("{at}-m"), app: "foundation.e.message".into(), label: "Message".into(), kind: "text".into(), talk: "t1".into(), name: "Cabinet du Dr Martin".into(), key: "+33199001234".into(), who: "neutral".into(), text: text.into(), shows: at, device: "phone".into(), ..Line::default() };
1127        let mut all = vec![m(NOW - 7_200_000, "Bonjour,"), m(NOW - 7_100_000, "Votre rendez-vous est déplacé.")];
1128        all.push(Line { picture: true, text: String::new(), ..m(NOW - 600_000, "") });
1129        all.push(Line { at: NOW - 300_000, id: "code".into(), app: "foundation.e.message".into(), kind: "text".into(), name: "Ma Banque".into(), code: true, shows: NOW - 300_000, device: "phone".into(), ..Line::default() });
1130        let column = |at: i64| (if at < NOW - 3_600_000 { "sleep" } else { "work" }).to_string();
1131        let all_shown = |_: &Line| true;
1132        let l = lister_at(&now, &tr, &all_shown, &column);
1133        let lines = lines(&all, &calls::Seen::default(), &l);
1134        assert_eq!(lines.len(), 3, "{lines:#?}");
1135        assert_eq!(lines[0].head, "While you slept, Dr Martin wrote:");
1136        assert_eq!(lines[0].said.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), ["Bonjour,", "Votre rendez-vous est déplacé."]);
1137        assert!(lines[0].texted && lines[0].known && lines[0].dial == "+33199001234" && lines[0].number == "01 99 00 12 34");
1138        assert_eq!(lines[1].said[0].text, "a picture");
1139        assert_eq!(lines[2].head, "A code came from Ma Banque at 09:55. It stays on your phone.");
1140        // Seen on another device: gone.
1141        let mut seen = calls::Seen::default();
1142        seen.seen.extend(lines[0].ids.iter().cloned());
1143        assert_eq!(super::lines(&all, &seen, &l).len(), 2);
1144        // Not before the phone let it through.
1145        let later = Line { shows: NOW + 3_600_000, ..m(NOW - 60_000, "Plus tard") };
1146        assert!(super::lines(&[later], &calls::Seen::default(), &l).is_empty());
1147    }
1148
1149    /// A stranger's text in the evening shows at work the next morning, not
1150    /// during the night; a safe person's at once in the evening; a service's
1151    /// in work hours, as the automatons' row says; never while you sleep.
1152    #[test]
1153    fn the_porch_shows_each_at_the_time_its_sender_may_reach_you() {
1154        let attention = Attention::usual();
1155        let line = |who: &str| Line { at: NOW, id: "x".into(), app: "foundation.e.message".into(), kind: "text".into(), who: who.into(), text: "Bonjour".into(), shows: NOW, ..Line::default() };
1156        let at = |column: Column| attention::Now::time(column);
1157        assert!(!reaches_now(&attention, &line("stranger"), None, false, &[], &at(Column::Leisure)));
1158        assert!(!reaches_now(&attention, &line("stranger"), None, false, &[], &at(Column::Sleep)));
1159        assert!(reaches_now(&attention, &line("stranger"), None, false, &[], &at(Column::Work)));
1160        assert!(reaches_now(&attention, &line("safe"), None, false, &[], &at(Column::Leisure)));
1161        assert!(!reaches_now(&attention, &line("safe"), None, false, &[], &at(Column::Sleep)));
1162        assert!(reaches_now(&attention, &line("stranger"), None, true, &[], &at(Column::Sleep)), "Always through");
1163        assert!(reaches_now(&attention, &line("automaton"), None, false, &[], &at(Column::Work)));
1164        assert!(!reaches_now(&attention, &line("automaton"), None, false, &[], &at(Column::Sleep)));
1165        // This device's lists now: blocked here since, never.
1166        assert!(!reaches_now(&attention, &line("safe"), Some(Who::Blocked), false, &[], &at(Column::Work)));
1167        // A code's line: whatever the time.
1168        assert!(reaches_now(&attention, &Line { code: true, ..line("") }, None, false, &[], &at(Column::Sleep)));
1169    }
1170
1171    #[test]
1172    fn a_person_s_sheet_lists_their_week() {
1173        let tr = Translator::new("en");
1174        let now = jiff::Timestamp::from_millisecond(NOW).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap());
1175        let all = vec![
1176            Line { at: NOW - DAY_MS, id: "a".into(), app: "foundation.e.message".into(), kind: "text".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Bonjour".into(), ..Line::default() },
1177            Line { at: NOW - 600_000, id: "b".into(), app: "org.thoughtcrime.securesms".into(), label: "Signal".into(), kind: "chat".into(), key: "+33199001234".into(), who: "neutral".into(), withheld: true, ..Line::default() },
1178            Line { at: NOW - 300_000, id: "c".into(), app: "foundation.e.message".into(), kind: "text".into(), key: "+33465710042".into(), who: "neutral".into(), text: "Autre".into(), ..Line::default() },
1179        ];
1180        let column = |_: i64| String::new();
1181        let shows = |_: &Line| true;
1182        let l = lister_at(&now, &tr, &shows, &column);
1183        let said = history(&all, &BTreeSet::from(["+33199001234".to_string()]), &l);
1184        assert_eq!(said, ["Today at 09:50: a message on Signal; its words stay on your phone.", "Yesterday at 10:00: Bonjour"]);
1185    }
1186
1187    /// The listener's JSON as Java writes it (AppNotes.describe with
1188    /// PhoneMessages, android/jvm-checks/MessagesCheck.java): what `Posted`
1189    /// reads is untouched by the fields beside, which `Extra` reads.
1190    #[test]
1191    fn what_java_says_beside_reads_here() {
1192        let json = r#"{"key":"0|foundation.e.message|7|null|10123","package":"foundation.e.message","app":"Message","category":"msg","title":"Cabinet du Dr Martin","text":"","messages":[{"text":"","sender":{"name":"Cabinet du Dr Martin","key":"","uri":"tel:+33199001234","bot":false},"at":1791446340000,"picture":true}],"people":[],"sms_app":true,"visibility":-1,"when":1791446340000,"posted":1791446341000,"picture":false,"offer":{"reply":true,"read":false},"history":["J'arrive."]}"#;
1193        let posted: Posted = serde_json::from_str(json).unwrap();
1194        assert_eq!((posted.package.as_str(), posted.messages.len(), posted.sms_app), ("foundation.e.message", 1, true));
1195        let extra = Extra::read(json);
1196        assert_eq!(extra, Extra { visibility: SECRET, when: 1_791_446_340_000, posted: 1_791_446_341_000, picture: false, messages: vec![ExtraMessage { at: 1_791_446_340_000, picture: true }], offer: Offer { reply: true, read: false }, history: vec!["J'arrive.".into()] });
1197        // An older Java, which said none of it: nothing secret, no time, no picture.
1198        assert_eq!(Extra::read(r#"{"package":"foundation.e.message","messages":[{"text":"Bonjour"}]}"#), Extra { messages: vec![ExtraMessage::default()], ..Extra::default() });
1199    }
1200
1201    #[test]
1202    fn the_phone_s_choices_by_app() {
1203        let mut choices = Choices::default();
1204        assert_eq!(choices.send("foundation.e.message", true), Send::Words, "the SMS app: on");
1205        assert_eq!(choices.send("org.thoughtcrime.securesms", false), Send::Off, "a chat app: off until chosen");
1206        choices.sms_app = "foundation.e.message".into();
1207        assert_eq!(choices.send("foundation.e.message", false), Send::Words, "as the listener noted it");
1208        choices.set("org.thoughtcrime.securesms", Send::Who, "Signal");
1209        choices.set("foundation.e.message", Send::Off, "Message");
1210        assert_eq!((choices.send("org.thoughtcrime.securesms", false), choices.send("foundation.e.message", true)), (Send::Who, Send::Off));
1211        choices.set("foundation.e.message", Send::Usual, "Message");
1212        assert!(!choices.app.contains_key("foundation.e.message"));
1213        let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-choices-{}", std::process::id()));
1214        let path = dir.join(CHOICES_FILE);
1215        choices.save(&path).unwrap();
1216        assert_eq!(Choices::load(&path), choices);
1217        let _ = std::fs::remove_dir_all(&dir);
1218    }
1219}