1use 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
51pub const PART: &str = "phone-messages";
53pub const FOLDER: &str = "phone-messages";
55pub const LOG: &str = "log";
57pub const SEEN_LOG: &str = calls::SEEN_LOG;
60pub const KEPT_DAYS: i64 = 7;
62pub const LATE_DAYS: i64 = 3;
64pub const TEXT_MAX: usize = 1000;
66pub const PAD_STEP: usize = 256;
68pub const CHOICES_FILE: &str = "phone-messages.toml";
70pub const SECRET: i32 = -1;
72const NAME_MAX: usize = 80;
74const DAY_MS: i64 = 86_400_000;
76
77pub fn folder() -> PathBuf {
79 crate::config::state_dir().join(FOLDER)
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum Send {
88 #[default]
90 Usual,
91 Off,
93 Who,
95 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 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct Choices {
138 #[serde(default, skip_serializing_if = "String::is_empty")]
140 pub sms_app: String,
141 #[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 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 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
190#[serde(default)]
191pub struct ExtraMessage {
192 pub at: i64,
193 pub picture: bool,
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
199#[serde(default)]
200pub struct Extra {
201 pub visibility: i32,
203 pub when: i64,
205 pub posted: i64,
207 pub picture: bool,
209 pub messages: Vec<ExtraMessage>,
211}
212
213impl Extra {
214 pub fn read(json: &str) -> Extra {
216 serde_json::from_str(json).unwrap_or_default()
217 }
218}
219
220#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
224pub struct Line {
225 pub at: i64,
227 pub id: String,
229 pub app: String,
231 #[serde(default, skip_serializing_if = "String::is_empty")]
232 pub label: String,
233 pub kind: String,
235 #[serde(default, skip_serializing_if = "String::is_empty")]
237 pub talk: String,
238 #[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 #[serde(default, skip_serializing_if = "String::is_empty")]
245 pub name: String,
246 #[serde(default, skip_serializing_if = "String::is_empty")]
248 pub key: String,
249 #[serde(default, skip_serializing_if = "String::is_empty")]
253 pub who: String,
254 #[serde(default, skip_serializing_if = "is_false")]
256 pub always: bool,
257 #[serde(default, skip_serializing_if = "String::is_empty")]
259 pub text: String,
260 #[serde(default, skip_serializing_if = "is_false")]
262 pub picture: bool,
263 #[serde(default, skip_serializing_if = "is_false")]
265 pub code: bool,
266 #[serde(default, skip_serializing_if = "is_false")]
268 pub withheld: bool,
269 #[serde(default)]
271 pub shows: i64,
272 #[serde(default, skip_serializing_if = "String::is_empty")]
274 pub pad: String,
275 #[serde(skip)]
277 pub device: String,
278}
279
280fn is_false(value: &bool) -> bool {
281 !*value
282}
283
284pub 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 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
299fn one_line(text: &str, max: usize) -> String {
301 let joined = text.split_whitespace().collect::<Vec<_>>().join(" ");
302 cut(&joined, max)
303}
304
305fn 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
313pub fn words_of(text: &str) -> String {
315 cut(text.trim(), TEXT_MAX)
316}
317
318pub struct Notice<'a> {
322 pub posted: &'a Posted,
323 pub extra: &'a Extra,
324 pub kind: &'a Kind,
325 pub who: Option<Who>,
327 pub listed: bool,
329 pub decision: &'a Decision,
330 pub send: Send,
332 pub now_ms: i64,
333 pub region: Option<&'static Region>,
334 pub words: &'a crate::words::Words,
335}
336
337fn 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
345fn 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
354fn 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 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
374pub 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 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
434fn 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 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
461pub 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
470pub 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
484pub 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
510fn 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
515pub 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
523pub 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
539pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
579pub struct Said {
580 pub time: String,
582 pub from: String,
584 pub text: String,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
591pub struct PorchLine {
592 pub ids: Vec<String>,
594 pub head: String,
597 pub said: Vec<Said>,
598 pub note: String,
600 pub number: String,
602 pub dial: String,
604 pub known: bool,
606 pub texted: bool,
608 pub code: bool,
609}
610
611pub struct Lister<'a> {
613 pub now: &'a Zoned,
614 pub tr: &'a Translator,
615 pub region: Option<&'static Region>,
616 pub name_of: &'a dyn Fn(&str) -> Option<String>,
618 pub shows: &'a dyn Fn(&Line) -> bool,
620 pub phone: &'a dyn Fn(&str) -> String,
623 pub column: &'a dyn Fn(i64) -> String,
625}
626
627fn 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
635fn 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
652fn 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
669pub 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 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 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
770pub 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 const NOW: i64 = 1_791_446_400_000;
811
812 fn fr() -> Option<&'static Region> {
813 phones::region_named("FR")
814 }
815
816 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 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 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 #[test]
886 fn what_must_stay_on_the_phone_never_travels() {
887 let bank = person("Ma Banque", "tel:+33465710042");
888 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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert!(!reaches_now(&attention, &line("safe"), Some(Who::Blocked), false, &at(Column::Work)));
1032 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 #[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 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}