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