1use crate::appnotes::{self, Decision, Kind, Person, Posted, Sender, Why};
47use crate::attention::{self, Attention, Column, Event, Level, Row, Source};
48use crate::calls;
49use crate::i18n::Translator;
50use crate::phones::{self, Region};
51use crate::reach::{Channel, Who};
52use jiff::Zoned;
53use serde::{Deserialize, Serialize};
54use std::collections::{BTreeMap, BTreeSet};
55use std::path::{Path, PathBuf};
56
57pub const PART: &str = "phone-messages";
59pub const FOLDER: &str = "phone-messages";
61pub const LOG: &str = "log";
63pub const SEEN_LOG: &str = calls::SEEN_LOG;
66pub const KEPT_DAYS: i64 = 7;
68pub const LATE_DAYS: i64 = 3;
70pub const TEXT_MAX: usize = 1000;
72pub const PAD_STEP: usize = 256;
74pub const CHOICES_FILE: &str = "phone-messages.toml";
76pub const SECRET: i32 = -1;
78const NAME_MAX: usize = 80;
80const DAY_MS: i64 = 86_400_000;
82
83pub fn folder() -> PathBuf {
85 crate::config::state_dir().join(FOLDER)
86}
87
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93pub enum Send {
94 #[default]
96 Usual,
97 Off,
99 Who,
101 Words,
103}
104
105impl Send {
106 pub const ALL: [Send; 4] = [Send::Usual, Send::Off, Send::Who, Send::Words];
107
108 pub fn id(self) -> &'static str {
109 match self {
110 Send::Usual => "usual",
111 Send::Off => "off",
112 Send::Who => "who",
113 Send::Words => "words",
114 }
115 }
116
117 pub fn read(id: &str) -> Option<Send> {
118 Send::ALL.into_iter().find(|s| s.id() == id)
119 }
120
121 pub fn resolved(self, sms_app: bool) -> Send {
123 match self {
124 Send::Usual if sms_app => Send::Words,
125 Send::Usual => Send::Off,
126 other => other,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct AppChoice {
134 #[serde(default)]
135 pub send: Send,
136 #[serde(default, skip_serializing_if = "String::is_empty")]
137 pub label: String,
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Choices {
144 #[serde(default, skip_serializing_if = "String::is_empty")]
146 pub sms_app: String,
147 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149 pub app: BTreeMap<String, AppChoice>,
150 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
153 pub no_answers: BTreeSet<String>,
154}
155
156impl Choices {
157 pub fn default_path() -> PathBuf {
158 crate::config::config_dir().join(CHOICES_FILE)
159 }
160
161 pub fn load(path: &Path) -> Choices {
163 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
164 }
165
166 pub fn save(&self, path: &Path) -> Result<(), String> {
168 let text = toml::to_string(self).map_err(|e| e.to_string())?;
169 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
170 let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
171 let _ = std::fs::remove_file(&fresh);
172 calls::append_private(&fresh, &text)?;
173 std::fs::rename(&fresh, path).map_err(|e| {
174 let _ = std::fs::remove_file(&fresh);
175 format!("{}: {e}", path.display())
176 })
177 }
178
179 pub fn send(&self, package: &str, sms_app: bool) -> Send {
181 let chosen = self.app.get(package).map_or(Send::Usual, |c| c.send);
182 chosen.resolved(sms_app || (!self.sms_app.is_empty() && package == self.sms_app))
183 }
184
185 pub fn answers(&self, package: &str) -> bool {
187 !self.no_answers.contains(package)
188 }
189
190 pub fn set_answers(&mut self, package: &str, on: bool) {
192 if on {
193 self.no_answers.remove(package);
194 } else {
195 self.no_answers.insert(package.to_string());
196 }
197 }
198
199 pub fn set(&mut self, package: &str, send: Send, label: &str) {
201 if send == Send::Usual {
202 self.app.remove(package);
203 } else {
204 self.app.insert(package.to_string(), AppChoice { send, label: label.to_string() });
205 }
206 }
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
214#[serde(default)]
215pub struct ExtraMessage {
216 pub at: i64,
217 pub picture: bool,
218}
219
220#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
225#[serde(default)]
226pub struct Offer {
227 pub reply: bool,
228 pub read: bool,
229}
230
231#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
234#[serde(default)]
235pub struct Extra {
236 pub visibility: i32,
238 pub when: i64,
240 pub posted: i64,
242 pub picture: bool,
244 pub messages: Vec<ExtraMessage>,
246 pub offer: Offer,
248 pub history: Vec<String>,
253}
254
255impl Extra {
256 pub fn read(json: &str) -> Extra {
258 serde_json::from_str(json).unwrap_or_default()
259 }
260}
261
262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct Line {
267 pub at: i64,
269 pub id: String,
271 pub app: String,
273 #[serde(default, skip_serializing_if = "String::is_empty")]
274 pub label: String,
275 pub kind: String,
277 #[serde(default, skip_serializing_if = "String::is_empty")]
279 pub talk: String,
280 #[serde(default, skip_serializing_if = "String::is_empty")]
285 pub notice: String,
286 #[serde(default, skip_serializing_if = "String::is_empty")]
288 pub title: String,
289 #[serde(default, skip_serializing_if = "is_false")]
290 pub group: bool,
291 #[serde(default, skip_serializing_if = "String::is_empty")]
293 pub name: String,
294 #[serde(default, skip_serializing_if = "String::is_empty")]
296 pub key: String,
297 #[serde(default, skip_serializing_if = "String::is_empty")]
301 pub who: String,
302 #[serde(default, skip_serializing_if = "is_false")]
304 pub always: bool,
305 #[serde(default, skip_serializing_if = "String::is_empty")]
307 pub text: String,
308 #[serde(default, skip_serializing_if = "is_false")]
310 pub picture: bool,
311 #[serde(default, skip_serializing_if = "is_false")]
313 pub code: bool,
314 #[serde(default, skip_serializing_if = "is_false")]
316 pub withheld: bool,
317 #[serde(default)]
319 pub shows: i64,
320 #[serde(default, skip_serializing_if = "String::is_empty")]
322 pub pad: String,
323 #[serde(skip)]
325 pub device: String,
326}
327
328fn is_false(value: &bool) -> bool {
329 !*value
330}
331
332pub fn padded(line: &Line) -> String {
336 let bare = Line { pad: String::new(), device: String::new(), ..line.clone() };
337 let text = serde_json::to_string(&bare).unwrap_or_default();
338 if text.len() % PAD_STEP == 0 {
339 return text;
340 }
341 let room = text.len() + 9;
343 let spaces = (PAD_STEP - room % PAD_STEP) % PAD_STEP;
344 serde_json::to_string(&Line { pad: " ".repeat(spaces), ..bare }).unwrap_or(text)
345}
346
347pub(crate) fn one_line(text: &str, max: usize) -> String {
349 let joined = text.split_whitespace().collect::<Vec<_>>().join(" ");
350 cut(&joined, max)
351}
352
353fn cut(text: &str, max: usize) -> String {
355 match text.char_indices().nth(max) {
356 Some((at, _)) => format!("{}…", text[..at].trim_end()),
357 None => text.to_string(),
358 }
359}
360
361pub fn words_of(text: &str) -> String {
363 cut(text.trim(), TEXT_MAX)
364}
365
366pub struct Notice<'a> {
370 pub posted: &'a Posted,
371 pub extra: &'a Extra,
372 pub kind: &'a Kind,
373 pub who: Option<Who>,
375 pub listed: bool,
377 pub decision: &'a Decision,
378 pub send: Send,
380 pub now_ms: i64,
381 pub region: Option<&'static Region>,
382 pub words: &'a crate::words::Words,
383}
384
385pub(crate) fn is_user(p: &Posted, sender: &Person) -> bool {
387 p.user.as_ref().is_some_and(|user| {
388 let (key, name) = (user.key.trim(), one_line(&user.name, NAME_MAX));
389 (!key.is_empty() && key == sender.key.trim()) || (!name.is_empty() && name == one_line(&sender.name, NAME_MAX))
390 })
391}
392
393fn number_key(person: Option<&Person>, fallback_name: &str, region: Option<&Region>) -> String {
396 let from_uri = person.and_then(|p| p.uri.trim().strip_prefix("tel:")).map(str::to_string);
397 let from_book = person.and_then(|p| p.numbers.iter().find(|n| !n.trim().is_empty()).cloned());
398 let from_name = appnotes::number_in(person.map_or(fallback_name, |p| p.name.as_str()));
399 from_uri.or(from_book).or(from_name).map(|n| phones::key(&n.replace("%2B", "+"), region)).unwrap_or_default()
400}
401
402pub(crate) fn writers(p: &Posted, region: Option<&Region>) -> (Vec<String>, Vec<String>) {
407 let senders: Vec<&Person> = p.messages.iter().filter_map(|m| m.sender.as_ref()).filter(|s| !is_user(p, s)).chain(p.people.iter()).collect();
408 let numbers = senders.iter().map(|s| number_key(Some(s), "", region)).chain(std::iter::once(number_key(p.people.first(), &p.title, region))).filter(|k| !k.is_empty()).collect();
409 let names = senders.iter().map(|s| one_line(&s.name, NAME_MAX)).chain(std::iter::once(one_line(&p.title, NAME_MAX))).filter(|n| !n.is_empty()).collect();
410 (numbers, names)
411}
412
413pub(crate) fn newest_timed_from_others(p: &Posted, extra: &Extra) -> i64 {
418 p.messages.iter().enumerate().filter(|(_, m)| m.sender.as_ref().is_some_and(|s| !is_user(p, s))).filter_map(|(i, _)| extra.messages.get(i).map(|e| e.at)).max().unwrap_or(0)
419}
420
421pub(crate) fn title_of(c: &appnotes::Conversation) -> String {
423 if c.group { one_line(&c.title, NAME_MAX) } else { String::new() }
424}
425
426fn code_sender(p: &Posted, words: &crate::words::Words) -> String {
431 let last = p.messages.iter().rev().find_map(|m| m.sender.as_ref().filter(|s| !is_user(p, s))).map(|s| s.name.as_str());
432 let named = [last.unwrap_or(""), p.people.first().map_or("", |s| s.name.as_str()), p.conversation.as_str(), p.title.as_str()].into_iter().map(|n| one_line(n, NAME_MAX)).find(|n| !n.is_empty()).unwrap_or_default();
433 let mut run = 0;
435 let coded = named.chars().any(|c| {
436 run = if c.is_ascii_digit() { run + 1 } else { 0 };
437 run >= 4
438 });
439 if named.is_empty() || crate::codes::detect(&words.codes, &named, "").is_some() || (coded && appnotes::number_in(&named).is_none()) {
440 one_line(&p.app, NAME_MAX)
441 } else {
442 named
443 }
444}
445
446pub fn lines_of(n: &Notice) -> Vec<Line> {
457 let p = n.posted;
458 let send = n.send;
459 if matches!(send, Send::Off | Send::Usual) || p.package.is_empty() || p.package == appnotes::OWN || p.ongoing || p.summary || p.full_screen || n.extra.visibility == SECRET {
460 return Vec::new();
461 }
462 if n.who == Some(Who::Blocked) || n.decision.why == Why::Never {
463 return Vec::new();
464 }
465 let heard = [n.extra.when, n.extra.posted].into_iter().find(|t| *t > 0).unwrap_or(n.now_ms);
466 let shows = n.decision.until.map_or(n.now_ms, |until| until.saturating_mul(1000)).max(heard.min(n.now_ms));
467 let sms = p.sms_app || matches!(n.kind, Kind::People(talk) if talk.sms);
468 let label = one_line(&p.app, NAME_MAX);
469 let notice = if p.key.is_empty() { String::new() } else { crate::replies::notice(&p.package, &p.key) };
470 let base = Line { app: p.package.clone(), label: label.clone(), notice, shows, ..Line::default() };
471 let mut lines = match n.kind {
472 Kind::Untouched => return Vec::new(),
473 Kind::Code => {
474 let name = code_sender(p, n.words);
475 vec![Line { at: heard, kind: (if sms { "text" } else { "app" }).into(), name, code: true, shows: heard.min(n.now_ms), ..base }]
476 }
477 Kind::People(talk) => {
478 if talk.via == Channel::Calls {
479 return Vec::new();
480 }
481 let kind = if sms { "text" } else if talk.via == Channel::Mail { "mail" } else { "chat" };
482 let service = sms && n.who.is_none() && matches!(talk.sender, Sender::Named { .. });
484 let who = if service { "automaton".to_string() } else { n.who.unwrap_or(Who::Stranger).id().to_string() };
485 let (talk_key, title, group) = talk.conversation.as_ref().map_or((String::new(), String::new(), false), |c| (c.key.clone(), title_of(c), c.group));
486 let line = Line { kind: kind.into(), talk: talk_key, title, group, who, always: n.listed && !group, ..base };
487 messages_of(n, line, heard)
488 }
489 Kind::Automaton { .. } | Kind::AtOnce => {
490 let who = if matches!(n.kind, Kind::AtOnce) { "at-once" } else { "automaton" };
491 let line = Line { kind: (if sms { "text" } else { "app" }).into(), who: who.into(), ..base };
492 messages_of(n, line, heard)
493 }
494 };
495 for line in &mut lines {
496 if send == Send::Who && !line.code {
497 line.text.clear();
498 line.picture = false;
499 line.withheld = true;
500 }
501 line.id = format!("{}-{}", line.at, appnotes::talk_key(&line.app, &format!("{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}", line.talk, line.name, line.key, line.text, line.code)));
502 }
503 lines.retain(|l| l.at > 0 && n.now_ms - l.at <= KEPT_DAYS * DAY_MS);
504 lines
505}
506
507fn messages_of(n: &Notice, line: Line, heard: i64) -> Vec<Line> {
511 let p = n.posted;
512 let region = n.region;
513 let from_others: Vec<(usize, &appnotes::Message, &Person)> = p.messages.iter().enumerate().filter_map(|(i, m)| m.sender.as_ref().filter(|s| !is_user(p, s)).map(|s| (i, m, s))).collect();
514 if !from_others.is_empty() {
515 let timed = from_others.iter().all(|(i, _, _)| n.extra.messages.get(*i).is_some_and(|e| e.at > 0));
516 let chosen: Vec<&(usize, &appnotes::Message, &Person)> = if timed { from_others.iter().collect() } else { from_others.last().into_iter().collect() };
517 let theirs = (!line.group).then(|| p.people.iter().find(|s| s.uri.trim().starts_with("tel:"))).flatten();
519 return chosen
520 .into_iter()
521 .map(|(i, message, sender)| {
522 let extra = n.extra.messages.get(*i).cloned().unwrap_or_default();
523 let key = Some(number_key(Some(sender), "", region)).filter(|k| !k.is_empty()).unwrap_or_else(|| theirs.map(|t| number_key(Some(t), "", region)).unwrap_or_default());
524 Line { at: if timed { extra.at } else { heard }, name: one_line(&sender.name, NAME_MAX), key, text: words_of(&message.text), picture: extra.picture, ..line.clone() }
525 })
526 .collect();
527 }
528 let person = p.people.first();
529 let name = [person.map_or("", |s| s.name.as_str()), p.title.as_str()].into_iter().map(|t| one_line(t, NAME_MAX)).find(|t| !t.is_empty()).unwrap_or_default();
530 let words = [p.big.as_str(), p.text.as_str()].into_iter().find(|t| !t.trim().is_empty()).map(str::to_string).unwrap_or_else(|| p.lines.join("\n"));
531 vec![Line { at: heard, key: number_key(person, &name, region), name, text: words_of(&words), picture: n.extra.picture, ..line }]
532}
533
534pub fn read_file(path: &Path) -> Vec<Line> {
540 let mut lines: Vec<Line> = Vec::new();
541 let mut place: BTreeMap<String, usize> = BTreeMap::new();
542 for line in calls::read_lines::<Line>(path).into_iter().filter(|l| l.at > 0 && !l.id.is_empty()) {
543 match place.get(&line.id) {
544 Some(&i) => lines[i].shows = lines[i].shows.min(line.shows),
545 None => {
546 place.insert(line.id.clone(), lines.len());
547 lines.push(line);
548 }
549 }
550 }
551 lines.sort_by_key(|l| l.at);
552 lines
553}
554
555pub const EARLIER_MS: i64 = 60_000;
558
559pub fn read_logs(root: &Path, now_ms: i64) -> Vec<Line> {
564 let mut lines: Vec<Line> = calls::device_files(&root.join(LOG))
565 .into_iter()
566 .flat_map(|(device, path)| read_file(&path).into_iter().map(move |l| Line { device: device.clone(), ..l }))
567 .filter(|l| l.who != "blocked" && now_ms - l.at <= KEPT_DAYS * DAY_MS)
568 .collect();
569 lines.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
570 lines
571}
572
573pub fn carry_into(own: &Path, lines: &[Line], now_ms: i64) -> Result<usize, String> {
582 let fresh: Vec<&Line> = lines.iter().filter(|l| l.at > 0 && !l.id.is_empty() && now_ms - l.at <= KEPT_DAYS * DAY_MS).collect();
583 if fresh.is_empty() {
584 return Ok(0);
585 }
586 crate::filelock::with_lock(own, || {
587 let mut known: BTreeMap<String, i64> = read_file(own).into_iter().map(|l| (l.id, l.shows)).collect();
588 let mut text = String::new();
589 for line in fresh {
590 if known.get(&line.id).is_none_or(|shows| line.shows + EARLIER_MS < *shows) {
591 known.insert(line.id.clone(), line.shows);
592 text.push_str(&padded(line));
593 text.push('\n');
594 }
595 }
596 let written = text.lines().count();
597 if written > 0 {
598 calls::append_private(own, &text)?;
599 }
600 Ok(written)
601 })
602}
603
604fn older_than(days: i64, now_ms: i64) -> impl Fn(&str) -> bool {
606 move |line: &str| serde_json::from_str::<serde_json::Value>(line).ok().and_then(|v| v["at"].as_i64()).is_some_and(|at| now_ms - at > days * DAY_MS)
607}
608
609pub fn trim_own(root: &Path, device: &str, now_ms: i64) -> Result<usize, String> {
613 let old = older_than(KEPT_DAYS, now_ms);
614 let mut gone = 0;
615 for folder in [LOG, SEEN_LOG, crate::replies::LIVE] {
616 gone += calls::rewrite_lines(&calls::own_file(root, folder, device), &old)?;
617 }
618 Ok(gone)
619}
620
621pub fn trim_others(root: &Path, here: &str, now_ms: i64) -> Result<usize, String> {
625 let old = older_than(KEPT_DAYS + LATE_DAYS, now_ms);
626 let mut gone = 0;
627 for folder in [LOG, SEEN_LOG, crate::replies::LIVE] {
628 for (device, path) in calls::device_files(&root.join(folder)) {
629 if device != calls::file_safe(here) {
630 gone += calls::rewrite_lines(&path, &old)?;
631 }
632 }
633 }
634 Ok(gone)
635}
636
637pub fn reaches_now(attention: &Attention, line: &Line, who: Option<Who>, always: bool, persons: &[String], now: &attention::Now) -> bool {
652 if line.code {
653 return true;
654 }
655 let sources = attention::source_rows_with(&line.app, &line.talk, persons);
656 if matches!(line.who.as_str(), "automaton" | "at-once") {
657 let kind = if line.who == "at-once" { attention::Kind::AppAtOnce } else { attention::Kind::AppAutomatons };
658 return matches!(attention.decide(&Event::own(kind).from_sources(sources), now).level, Level::Now | Level::Quiet | Level::Gathered);
659 }
660 let who = who.or_else(|| Who::read(&line.who)).unwrap_or(Who::Stranger);
661 if who == Who::Blocked {
662 return false;
663 }
664 let via = if line.kind == "mail" { Channel::Mail } else { Channel::Messages };
665 let always = always && !line.group;
666 if attention.decide(&Event::of(Source::Message { via, who, group: line.group, always }).from_sources(sources.clone()), now).level == Level::Now {
667 return true;
668 }
669 let person = if line.group { attention::Person::Groups } else { attention::Person::of(who) };
670 let row = Row::People(via, person);
671 let never = !always && !attention.has_sources(&sources) && Column::TIMES.iter().all(|c| attention.cell(row, *c) != Level::Now);
672 never && !now.dnd && now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin))
673}
674
675#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
679pub struct Said {
680 pub time: String,
682 pub from: String,
684 pub text: String,
686}
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
691pub struct PorchLine {
692 pub ids: Vec<String>,
694 pub head: String,
697 pub said: Vec<Said>,
698 pub note: String,
700 pub number: String,
702 pub dial: String,
704 pub known: bool,
706 pub texted: bool,
708 pub code: bool,
709 pub who: String,
712 #[serde(skip_serializing_if = "Option::is_none")]
715 pub answer: Option<crate::replies::Answer>,
716}
717
718pub struct Lister<'a> {
720 pub now: &'a Zoned,
721 pub tr: &'a Translator,
722 pub region: Option<&'static Region>,
723 pub name_of: &'a dyn Fn(&str) -> Option<String>,
725 pub shows: &'a dyn Fn(&Line) -> bool,
727 pub phone: &'a dyn Fn(&str) -> String,
730 pub column: &'a dyn Fn(i64) -> String,
732}
733
734fn context(tr: &Translator, column: &str) -> String {
736 match column {
737 "work" | "admin" | "leisure" | "meals" | "sleep" | "pause" | "free" | "slot" | "dnd" => calls::context(tr, column),
738 _ => tr.text("phonemsgs-context-any", None),
739 }
740}
741
742fn who_words(line: &Line, l: &Lister) -> (String, bool) {
746 if let Some(name) = (!line.key.is_empty()).then(|| (l.name_of)(&line.key)).flatten() {
747 return (name, true);
748 }
749 let name = line.name.trim();
750 if !name.is_empty() && appnotes::number_in(name).is_none() {
751 return (name.to_string(), false);
752 }
753 if !line.key.is_empty() || !name.is_empty() {
754 return (l.tr.text("calls-who-stranger", None), false);
755 }
756 (l.tr.text("phonemsgs-who-someone", None), false)
757}
758
759fn said_text(line: &Line, tr: &Translator) -> String {
761 if line.withheld {
762 return String::new();
763 }
764 match (line.text.trim().is_empty(), line.picture) {
765 (true, true) => tr.text("phonemsgs-picture", None),
766 (false, true) => {
767 let mut args = crate::i18n::args();
768 args.set("text", line.text.clone());
769 tr.text("phonemsgs-with-picture", Some(&args))
770 }
771 (true, false) => tr.text("phonemsgs-no-words", None),
772 (false, false) => line.text.clone(),
773 }
774}
775
776pub fn lines(all: &[Line], seen: &calls::Seen, l: &Lister) -> Vec<PorchLine> {
782 let now_ms = l.now.timestamp().as_millisecond();
783 let shown: Vec<&Line> = all.iter().filter(|m| m.who != "blocked" && !seen.seen.contains(&m.id) && now_ms - m.at <= KEPT_DAYS * DAY_MS && m.at <= now_ms + 60_000 && now_ms >= m.shows && (l.shows)(m)).collect();
784 type Group<'a> = (String, String, jiff::civil::Date, Vec<&'a Line>);
785 let mut groups: Vec<Group> = Vec::new();
786 for m in shown {
787 let column = (l.column)(m.at);
788 let date = calls::local(m.at, l.now).date();
789 let who = if m.code {
790 format!("code\u{1f}{}", m.id)
791 } else if !m.talk.is_empty() {
792 m.talk.clone()
793 } else if !m.key.is_empty() {
794 m.key.clone()
795 } else {
796 format!("{}\u{1f}{}", m.app, m.name)
797 };
798 let group_key = format!("{}\u{1f}{who}", m.device);
799 match groups.iter_mut().find(|(k, c, d, _)| *k == group_key && *c == column && *d == date) {
800 Some((_, _, _, group)) => group.push(m),
801 None => groups.push((group_key, column, date, vec![m])),
802 }
803 }
804 groups.into_iter().map(|(_, column, date, group)| porch_line(&group, &column, date, l)).collect()
805}
806
807fn porch_line(group: &[&Line], column: &str, date: jiff::civil::Date, l: &Lister) -> PorchLine {
808 let tr = l.tr;
809 let first = group[0];
810 let time = |m: &Line| calls::local(m.at, l.now).strftime("%H:%M").to_string();
811 let today = l.now.date();
812 let ids: Vec<String> = group.iter().map(|m| m.id.clone()).collect();
813 let keyed = group.iter().find(|m| !m.key.is_empty() && !m.group).map(|m| m.key.clone()).unwrap_or_default();
814 let (who, known) = who_words(first, l);
815 let phone = Some((l.phone)(&first.device)).filter(|p| *p != tr.text("calls-phone-yours", None) && *p != tr.text("calls-phone-other", None)).unwrap_or_default();
817 if first.code {
818 let mut args = crate::i18n::args();
819 args.set("who", who);
820 args.set("time", time(first));
821 let head = if date == today {
822 tr.text("phonemsgs-code", Some(&args))
823 } else {
824 args.set("day", calls::day_words(tr, date, today));
825 tr.text("phonemsgs-code-day", Some(&args))
826 };
827 return PorchLine { ids, head: calls::capitalized(&head), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known, texted: false, code: true, who: String::new(), answer: None };
828 }
829 let mut context_said = context(tr, column);
830 if date != today {
831 let mut args = crate::i18n::args();
832 args.set("day", calls::day_words(tr, date, today));
833 args.set("context", context_said);
834 context_said = tr.text("calls-context-day", Some(&args));
835 }
836 let app = if first.kind == "text" { String::new() } else { first.label.clone() };
838 let named = if first.group && !first.title.is_empty() { first.title.clone() } else { who.clone() };
839 let mut args = crate::i18n::args();
840 args.set("context", context_said);
841 args.set("who", who);
842 args.set("group", first.title.clone());
843 args.set("app", app.clone());
844 args.set("phone", phone.clone());
845 let shape = if first.group { "-group" } else { "" };
846 let with_app = if app.is_empty() { "" } else { "-app" };
847 let on = if phone.is_empty() { "" } else { "-on" };
848 let head = calls::capitalized(&tr.text(&format!("phonemsgs-head{shape}{with_app}{on}"), Some(&args)));
849 let said = group
850 .iter()
851 .map(|m| Said {
852 time: time(m),
853 from: if m.group { (!m.key.is_empty()).then(|| (l.name_of)(&m.key)).flatten().unwrap_or_else(|| m.name.clone()) } else { String::new() },
854 text: said_text(m, tr),
855 })
856 .collect();
857 let note = if group.iter().any(|m| m.withheld) {
858 let mut args = crate::i18n::args();
859 args.set("app", if first.label.is_empty() { first.app.clone() } else { first.label.clone() });
860 tr.text("phonemsgs-withheld", Some(&args))
861 } else {
862 String::new()
863 };
864 let texted = first.kind == "text" && !first.group && first.who != "automaton" && phones::is_whole(&keyed);
865 PorchLine {
866 ids,
867 head,
868 said,
869 note,
870 number: if keyed.is_empty() { String::new() } else { calls::shown_number(&keyed, l.region) },
871 dial: if phones::is_whole(&keyed) { keyed.clone() } else { String::new() },
872 known: known || (!keyed.is_empty() && (l.name_of)(&keyed).is_some()),
873 texted,
874 code: false,
875 who: named,
876 answer: None,
877 }
878}
879
880pub fn history(all: &[Line], keys: &BTreeSet<String>, l: &Lister) -> Vec<String> {
885 let tr = l.tr;
886 let now_ms = l.now.timestamp().as_millisecond();
887 all.iter()
888 .rev()
889 .filter(|m| !m.key.is_empty() && keys.contains(&m.key) && m.who != "blocked" && now_ms - m.at <= KEPT_DAYS * DAY_MS && m.at <= now_ms + 60_000)
890 .map(|m| {
891 let at = calls::local(m.at, l.now);
892 let mut args = crate::i18n::args();
893 args.set("day", calls::day_words(tr, at.date(), l.now.date()));
894 args.set("time", at.strftime("%H:%M").to_string());
895 let when = tr.text("calls-history-when", Some(&args));
896 let mut args = crate::i18n::args();
897 args.set("when", when);
898 args.set("app", if m.label.is_empty() { m.app.clone() } else { m.label.clone() });
899 args.set("text", said_text(m, tr));
900 let id = if m.code {
901 "phonemsgs-history-code"
902 } else if m.withheld {
903 "phonemsgs-history-withheld"
904 } else if m.kind == "text" {
905 "phonemsgs-history-line"
906 } else {
907 "phonemsgs-history-app"
908 };
909 calls::capitalized(&tr.text(id, Some(&args)))
910 })
911 .collect()
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use crate::appnotes::{Choices as AppChoices, Message};
918
919 const NOW: i64 = 1_791_446_400_000;
921
922 fn fr() -> Option<&'static Region> {
923 phones::region_named("FR")
924 }
925
926 fn words() -> std::sync::Arc<crate::words::Words> {
929 let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
930 crate::words::Words::of(&config)
931 }
932
933 fn person(name: &str, uri: &str) -> Person {
934 Person { name: name.into(), key: uri.into(), uri: uri.into(), ..Person::default() }
935 }
936
937 fn texted(from: &Person, texts: &[&str]) -> (Posted, Extra) {
940 let posted = Posted {
941 key: "0|foundation.e.message|1|null|10123".into(),
942 package: "foundation.e.message".into(),
943 app: "Message".into(),
944 category: "msg".into(),
945 template: "android.app.Notification$MessagingStyle".into(),
946 title: from.name.clone(),
947 text: texts.last().map_or(String::new(), |t| t.to_string()),
948 messages: texts.iter().map(|t| Message { sender: Some(from.clone()), text: t.to_string() }).collect(),
949 people: vec![Person { uri: from.uri.clone(), ..Person::default() }],
950 sms_app: true,
951 ..Posted::default()
952 };
953 let count = texts.len() as i64;
954 let extra = Extra { visibility: 0, when: NOW - 60_000, posted: NOW - 59_000, picture: false, messages: (0..count).map(|i| ExtraMessage { at: NOW - 600_000 + i * 60_000, picture: false }).collect(), ..Extra::default() };
955 (posted, extra)
956 }
957
958 fn decided(posted: &Posted, extra: &Extra, who: Option<Who>, decision: Decision, send: Send) -> Vec<Line> {
959 let words = words();
960 let kind = appnotes::classify(posted, &AppChoices::default(), &words);
961 lines_of(&Notice { posted, extra, kind: &kind, who, listed: false, decision: &decision, send, now_ms: NOW, region: fr(), words: &words })
962 }
963
964 fn now_through() -> Decision {
965 Decision::through(Why::Allowed)
966 }
967
968 #[test]
969 fn a_text_makes_a_line_per_message_with_its_number_and_words() {
970 let martin = person("Cabinet du Dr Martin", "tel:+33199001234");
971 let (posted, extra) = texted(&martin, &["Bonjour,", "Votre rendez-vous est déplacé à jeudi 10 h 30."]);
972 let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words);
973 assert_eq!(lines.len(), 2);
974 let last = &lines[1];
975 assert_eq!((last.kind.as_str(), last.name.as_str(), last.key.as_str(), last.who.as_str()), ("text", "Cabinet du Dr Martin", "+33199001234", "neutral"));
976 assert_eq!(last.text, "Votre rendez-vous est déplacé à jeudi 10 h 30.");
977 assert_eq!((last.at, last.shows), (NOW - 540_000, NOW));
978 assert_eq!(last.notice, crate::replies::notice("foundation.e.message", "0|foundation.e.message|1|null|10123"));
980 assert!(!serde_json::to_string(last).unwrap().contains("null|10123"), "Android's key stays on the phone");
981 assert!(last.id.starts_with(&format!("{}-", NOW - 540_000)));
982 let (again, extra) = texted(&martin, &["Bonjour,", "Votre rendez-vous est déplacé à jeudi 10 h 30.", "Merci de confirmer."]);
984 let more = decided(&again, &extra, Some(Who::Neutral), now_through(), Send::Words);
985 let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-again-{}", std::process::id()));
986 let _ = std::fs::remove_dir_all(&dir);
987 let own = calls::own_file(&dir, LOG, "phone");
988 assert_eq!(carry_into(&own, &lines, NOW).unwrap(), 2);
989 assert_eq!(carry_into(&own, &more, NOW).unwrap(), 1, "only the new message");
990 assert_eq!(read_file(&own).len(), 3);
991 let _ = std::fs::remove_dir_all(&dir);
992 }
993
994 #[test]
999 fn a_message_let_through_earlier_shows_earlier_on_computers() {
1000 let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-earlier-{}", std::process::id()));
1001 let _ = std::fs::remove_dir_all(&dir);
1002 let own = calls::own_file(&dir, LOG, "phone");
1003 let hour = 3_600_000;
1004 let line = |shows: i64| Line { at: NOW, id: format!("{NOW}-a"), app: "foundation.e.message".into(), kind: "text".into(), who: "neutral".into(), text: "Bonjour".into(), shows, ..Line::default() };
1005 assert_eq!(carry_into(&own, &[line(NOW + 3 * hour)], NOW).unwrap(), 1);
1006 assert_eq!(carry_into(&own, &[line(NOW + 3 * hour)], NOW).unwrap(), 0, "the same");
1007 assert_eq!(carry_into(&own, &[line(NOW + 3 * hour - 30_000)], NOW).unwrap(), 0, "half a minute earlier: the same");
1008 assert_eq!(carry_into(&own, &[line(NOW + hour)], NOW + hour).unwrap(), 1, "let through two hours earlier");
1009 assert_eq!(carry_into(&own, &[line(NOW + 2 * hour)], NOW + 2 * hour).unwrap(), 0, "later than the earliest");
1010 let read = read_file(&own);
1011 assert_eq!((read.len(), read[0].shows), (1, NOW + hour));
1012 assert_eq!(read_logs(&dir, NOW + hour).len(), 1, "once on a computer");
1013 let _ = std::fs::remove_dir_all(&dir);
1014 }
1015
1016 #[test]
1021 fn what_must_stay_on_the_phone_never_travels() {
1022 let bank = person("Ma Banque", "tel:+33465710042");
1023 let (code, extra) = texted(&bank, &["Votre code de validation est 482913. Ne le communiquez à personne."]);
1025 let lines = decided(&code, &extra, Some(Who::Neutral), Decision::through(Why::Code), Send::Words);
1026 assert_eq!(lines.len(), 1);
1027 assert!(lines[0].code && lines[0].text.is_empty() && lines[0].name == "Ma Banque", "{lines:?}");
1028 assert!(!padded(&lines[0]).contains("482913"));
1029 let short = Person { name: "38015".into(), ..Person::default() };
1031 let (code, extra) = texted(&short, &["G-482913 est votre code de vérification."]);
1032 let lines = decided(&code, &extra, None, Decision::through(Why::Code), Send::Words);
1033 assert_eq!(lines.iter().map(|l| (l.code, l.name.as_str())).collect::<Vec<_>>(), [(true, "Message")]);
1034 let (mut redacted, extra) = texted(&bank, &["Sensitive notification content hidden"]);
1036 redacted.redacted = true;
1037 let lines = decided(&redacted, &extra, Some(Who::Neutral), Decision::through(Why::Code), Send::Words);
1038 assert!(lines.iter().all(|l| l.code && l.text.is_empty()), "{lines:?}");
1039 let martin = person("Cabinet du Dr Martin", "tel:+33199001234");
1041 let (posted, mut extra) = texted(&martin, &["Résultats disponibles."]);
1042 extra.visibility = SECRET;
1043 assert!(decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words).is_empty());
1044 let (posted, extra) = texted(&martin, &["Encore moi."]);
1046 assert!(decided(&posted, &extra, Some(Who::Blocked), Decision::held(NOW / 1000 + appnotes::FOR_GOOD, Why::Never), Send::Words).is_empty());
1047 assert!(decided(&posted, &extra, Some(Who::Neutral), Decision::held(NOW / 1000 + appnotes::FOR_GOOD, Why::Never), Send::Words).is_empty());
1048 assert!(decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Off).is_empty());
1050 assert!(decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Usual).is_empty(), "lines_of takes a resolved choice");
1051 let own = Posted { package: appnotes::OWN.into(), ..posted.clone() };
1053 assert!(decided(&own, &extra, Some(Who::Neutral), now_through(), Send::Words).is_empty());
1054 let ongoing = Posted { ongoing: true, ..posted.clone() };
1055 assert!(decided(&ongoing, &extra, Some(Who::Neutral), now_through(), Send::Words).is_empty());
1056 let (posted, mut extra) = texted(&martin, &[""]);
1058 extra.messages[0].picture = true;
1059 let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words);
1060 assert_eq!(lines.len(), 1);
1061 assert!(lines[0].picture && lines[0].text.is_empty());
1062 assert!(!padded(&lines[0]).contains("content://"));
1063 let (posted, extra) = texted(&martin, &["Résultats disponibles."]);
1065 let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Who);
1066 assert!(lines.len() == 1 && lines[0].withheld && lines[0].text.is_empty() && lines[0].name == "Cabinet du Dr Martin");
1067 }
1068
1069 #[test]
1070 fn a_service_s_text_travels_as_an_automaton_s_held_as_the_phone_holds_it() {
1071 let ameli = Person { name: "AMELI".into(), ..Person::default() };
1072 let (posted, extra) = texted(&ameli, &["Votre attestation est disponible sur votre compte."]);
1073 let at_13 = NOW / 1000 + 3 * 3600;
1074 let lines = decided(&posted, &extra, None, Decision::held(at_13, Why::Gathered), Send::Words);
1075 assert_eq!(lines.len(), 1);
1076 assert_eq!((lines[0].who.as_str(), lines[0].name.as_str(), lines[0].shows), ("automaton", "AMELI", at_13 * 1000));
1077 assert!(lines[0].key.is_empty(), "no number to text back");
1078 }
1079
1080 #[test]
1081 fn long_words_are_cut_and_every_line_weighs_steps_of_256_bytes() {
1082 let martin = person("Cabinet du Dr Martin", "tel:+33199001234");
1083 let long = "é".repeat(1500);
1084 let (posted, extra) = texted(&martin, &[long.as_str()]);
1085 let lines = decided(&posted, &extra, Some(Who::Neutral), now_through(), Send::Words);
1086 assert_eq!(lines[0].text.chars().count(), TEXT_MAX + 1, "cut, with its ellipsis");
1087 for text in ["", "Oui", "Votre rendez-vous est déplacé à jeudi 10 h 30.", &"x".repeat(240), &"x".repeat(247), &"x".repeat(1000)] {
1088 let line = Line { at: NOW, id: format!("{NOW}-0123456789abcdef"), app: "foundation.e.message".into(), kind: "text".into(), text: text.into(), shows: NOW, ..Line::default() };
1089 let written = padded(&line);
1090 assert_eq!(written.len() % PAD_STEP, 0, "{} bytes for {} characters", written.len(), text.len());
1091 let back: Line = serde_json::from_str(&written).unwrap();
1092 assert_eq!(Line { pad: String::new(), ..back }, line);
1093 }
1094 let weigh = |t: &str| padded(&Line { at: NOW, id: "x".into(), app: "a".into(), kind: "text".into(), text: t.into(), ..Line::default() }).len();
1096 assert_eq!(weigh("Oui"), weigh("Je serai là vers dix-huit heures, à demain"));
1097 }
1098
1099 #[test]
1100 fn a_phone_trims_its_own_after_a_week_and_any_device_another_s_after_ten_days() {
1101 let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-trim-{}", std::process::id()));
1102 let _ = std::fs::remove_dir_all(&dir);
1103 let line = |at: i64| Line { at, id: format!("{at}-a"), app: "foundation.e.message".into(), kind: "text".into(), who: "neutral".into(), text: "Bonjour".into(), shows: at, ..Line::default() };
1104 carry_into(&calls::own_file(&dir, LOG, "phone"), &[line(NOW - 3_600_000)], NOW).unwrap();
1105 calls::mark_seen(&dir, "desk", &[format!("{}-a", NOW - 3_600_000)], NOW).unwrap();
1106 assert_eq!(read_logs(&dir, NOW).len(), 1);
1107 let week = NOW + KEPT_DAYS * DAY_MS;
1108 assert!(read_logs(&dir, week).is_empty(), "past its week, read nowhere");
1109 assert_eq!(trim_others(&dir, "desk", week).unwrap(), 0, "the phone's own week: another device waits");
1110 assert_eq!(trim_own(&dir, "phone", week).unwrap(), 1);
1111 carry_into(&calls::own_file(&dir, LOG, "phone"), &[line(NOW)], NOW).unwrap();
1112 let ten = NOW + (KEPT_DAYS + LATE_DAYS) * DAY_MS + 1;
1113 assert_eq!(trim_others(&dir, "desk", ten).unwrap(), 1, "the phone gone: the desk takes its line out");
1114 assert_eq!(trim_own(&dir, "desk", ten).unwrap(), 1, "the desk's own seen line too");
1115 let _ = std::fs::remove_dir_all(&dir);
1116 }
1117
1118 fn lister_at<'a>(now: &'a Zoned, tr: &'a Translator, shows: &'a dyn Fn(&Line) -> bool, column: &'a dyn Fn(i64) -> String) -> Lister<'a> {
1119 Lister { now, tr, region: fr(), name_of: &|key: &str| (key == "+33199001234").then(|| "Dr Martin".to_string()), shows, phone: &|_: &str| String::new(), column }
1120 }
1121
1122 #[test]
1123 fn the_porch_groups_a_conversation_by_part_of_the_day_and_says_codes_and_pictures() {
1124 let tr = Translator::new("en");
1125 let now = jiff::Timestamp::from_millisecond(NOW).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap());
1126 let m = |at: i64, text: &str| Line { at, id: format!("{at}-m"), app: "foundation.e.message".into(), label: "Message".into(), kind: "text".into(), talk: "t1".into(), name: "Cabinet du Dr Martin".into(), key: "+33199001234".into(), who: "neutral".into(), text: text.into(), shows: at, device: "phone".into(), ..Line::default() };
1127 let mut all = vec![m(NOW - 7_200_000, "Bonjour,"), m(NOW - 7_100_000, "Votre rendez-vous est déplacé.")];
1128 all.push(Line { picture: true, text: String::new(), ..m(NOW - 600_000, "") });
1129 all.push(Line { at: NOW - 300_000, id: "code".into(), app: "foundation.e.message".into(), kind: "text".into(), name: "Ma Banque".into(), code: true, shows: NOW - 300_000, device: "phone".into(), ..Line::default() });
1130 let column = |at: i64| (if at < NOW - 3_600_000 { "sleep" } else { "work" }).to_string();
1131 let all_shown = |_: &Line| true;
1132 let l = lister_at(&now, &tr, &all_shown, &column);
1133 let lines = lines(&all, &calls::Seen::default(), &l);
1134 assert_eq!(lines.len(), 3, "{lines:#?}");
1135 assert_eq!(lines[0].head, "While you slept, Dr Martin wrote:");
1136 assert_eq!(lines[0].said.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), ["Bonjour,", "Votre rendez-vous est déplacé."]);
1137 assert!(lines[0].texted && lines[0].known && lines[0].dial == "+33199001234" && lines[0].number == "01 99 00 12 34");
1138 assert_eq!(lines[1].said[0].text, "a picture");
1139 assert_eq!(lines[2].head, "A code came from Ma Banque at 09:55. It stays on your phone.");
1140 let mut seen = calls::Seen::default();
1142 seen.seen.extend(lines[0].ids.iter().cloned());
1143 assert_eq!(super::lines(&all, &seen, &l).len(), 2);
1144 let later = Line { shows: NOW + 3_600_000, ..m(NOW - 60_000, "Plus tard") };
1146 assert!(super::lines(&[later], &calls::Seen::default(), &l).is_empty());
1147 }
1148
1149 #[test]
1153 fn the_porch_shows_each_at_the_time_its_sender_may_reach_you() {
1154 let attention = Attention::usual();
1155 let line = |who: &str| Line { at: NOW, id: "x".into(), app: "foundation.e.message".into(), kind: "text".into(), who: who.into(), text: "Bonjour".into(), shows: NOW, ..Line::default() };
1156 let at = |column: Column| attention::Now::time(column);
1157 assert!(!reaches_now(&attention, &line("stranger"), None, false, &[], &at(Column::Leisure)));
1158 assert!(!reaches_now(&attention, &line("stranger"), None, false, &[], &at(Column::Sleep)));
1159 assert!(reaches_now(&attention, &line("stranger"), None, false, &[], &at(Column::Work)));
1160 assert!(reaches_now(&attention, &line("safe"), None, false, &[], &at(Column::Leisure)));
1161 assert!(!reaches_now(&attention, &line("safe"), None, false, &[], &at(Column::Sleep)));
1162 assert!(reaches_now(&attention, &line("stranger"), None, true, &[], &at(Column::Sleep)), "Always through");
1163 assert!(reaches_now(&attention, &line("automaton"), None, false, &[], &at(Column::Work)));
1164 assert!(!reaches_now(&attention, &line("automaton"), None, false, &[], &at(Column::Sleep)));
1165 assert!(!reaches_now(&attention, &line("safe"), Some(Who::Blocked), false, &[], &at(Column::Work)));
1167 assert!(reaches_now(&attention, &Line { code: true, ..line("") }, None, false, &[], &at(Column::Sleep)));
1169 }
1170
1171 #[test]
1172 fn a_person_s_sheet_lists_their_week() {
1173 let tr = Translator::new("en");
1174 let now = jiff::Timestamp::from_millisecond(NOW).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap());
1175 let all = vec![
1176 Line { at: NOW - DAY_MS, id: "a".into(), app: "foundation.e.message".into(), kind: "text".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Bonjour".into(), ..Line::default() },
1177 Line { at: NOW - 600_000, id: "b".into(), app: "org.thoughtcrime.securesms".into(), label: "Signal".into(), kind: "chat".into(), key: "+33199001234".into(), who: "neutral".into(), withheld: true, ..Line::default() },
1178 Line { at: NOW - 300_000, id: "c".into(), app: "foundation.e.message".into(), kind: "text".into(), key: "+33465710042".into(), who: "neutral".into(), text: "Autre".into(), ..Line::default() },
1179 ];
1180 let column = |_: i64| String::new();
1181 let shows = |_: &Line| true;
1182 let l = lister_at(&now, &tr, &shows, &column);
1183 let said = history(&all, &BTreeSet::from(["+33199001234".to_string()]), &l);
1184 assert_eq!(said, ["Today at 09:50: a message on Signal; its words stay on your phone.", "Yesterday at 10:00: Bonjour"]);
1185 }
1186
1187 #[test]
1191 fn what_java_says_beside_reads_here() {
1192 let json = r#"{"key":"0|foundation.e.message|7|null|10123","package":"foundation.e.message","app":"Message","category":"msg","title":"Cabinet du Dr Martin","text":"","messages":[{"text":"","sender":{"name":"Cabinet du Dr Martin","key":"","uri":"tel:+33199001234","bot":false},"at":1791446340000,"picture":true}],"people":[],"sms_app":true,"visibility":-1,"when":1791446340000,"posted":1791446341000,"picture":false,"offer":{"reply":true,"read":false},"history":["J'arrive."]}"#;
1193 let posted: Posted = serde_json::from_str(json).unwrap();
1194 assert_eq!((posted.package.as_str(), posted.messages.len(), posted.sms_app), ("foundation.e.message", 1, true));
1195 let extra = Extra::read(json);
1196 assert_eq!(extra, Extra { visibility: SECRET, when: 1_791_446_340_000, posted: 1_791_446_341_000, picture: false, messages: vec![ExtraMessage { at: 1_791_446_340_000, picture: true }], offer: Offer { reply: true, read: false }, history: vec!["J'arrive.".into()] });
1197 assert_eq!(Extra::read(r#"{"package":"foundation.e.message","messages":[{"text":"Bonjour"}]}"#), Extra { messages: vec![ExtraMessage::default()], ..Extra::default() });
1199 }
1200
1201 #[test]
1202 fn the_phone_s_choices_by_app() {
1203 let mut choices = Choices::default();
1204 assert_eq!(choices.send("foundation.e.message", true), Send::Words, "the SMS app: on");
1205 assert_eq!(choices.send("org.thoughtcrime.securesms", false), Send::Off, "a chat app: off until chosen");
1206 choices.sms_app = "foundation.e.message".into();
1207 assert_eq!(choices.send("foundation.e.message", false), Send::Words, "as the listener noted it");
1208 choices.set("org.thoughtcrime.securesms", Send::Who, "Signal");
1209 choices.set("foundation.e.message", Send::Off, "Message");
1210 assert_eq!((choices.send("org.thoughtcrime.securesms", false), choices.send("foundation.e.message", true)), (Send::Who, Send::Off));
1211 choices.set("foundation.e.message", Send::Usual, "Message");
1212 assert!(!choices.app.contains_key("foundation.e.message"));
1213 let dir = std::env::temp_dir().join(format!("sioul-phonemsgs-choices-{}", std::process::id()));
1214 let path = dir.join(CHOICES_FILE);
1215 choices.save(&path).unwrap();
1216 assert_eq!(Choices::load(&path), choices);
1217 let _ = std::fs::remove_dir_all(&dir);
1218 }
1219}