1use crate::calls;
52use crate::i18n::Translator;
53use crate::phones::{self, Region};
54use jiff::Zoned;
55use serde::{Deserialize, Serialize};
56use serde_json::Value;
57use std::collections::{BTreeMap, BTreeSet};
58use std::path::{Path, PathBuf};
59
60pub const PART: &str = "texts";
62pub const FOLDER: &str = "texts";
64pub const LOG: &str = "log";
66pub const SEND: &str = "send";
68pub const OUTCOME: &str = "outcome";
70pub const STORES: [&str; 3] = [LOG, SEND, OUTCOME];
72pub const MARKS: &str = "marks";
75pub const UNREAD_DAYS: i64 = 7;
77pub const FOLD_MS: i64 = 3 * 60_000;
79pub const MEDIA: &str = "media";
81pub const LEDGER: &str = "ledger.jsonl";
84pub const MARK: &str = "ledger-mark.json";
85pub const READER: &str = "reader.json";
86pub const RESULTS: &str = "results.jsonl";
87pub const KEPT_DAYS: i64 = 31;
89pub const LATE_DAYS: i64 = 14;
91pub const EXPIRY_MS: i64 = 15 * 60_000;
93pub const AHEAD_MS: i64 = 2 * 60_000;
95pub const LEDGER_KEPT_MS: i64 = 30 * DAY_MS;
97pub const LOOK_AFTER_MS: i64 = 2 * 60_000;
99pub const LOOK_AFTER_MMS_MS: i64 = 40 * 60_000;
103pub const MMS_LAST_WORD_MS: i64 = 100 * 60_000;
109pub const LIMITS_AHEAD_MS: i64 = DAY_MS;
112pub const MMS_MAX_EVER: u64 = 8 * 1024 * 1024;
115pub const MEDIA_MAX: usize = 5;
117pub const WRITE_MAX: usize = 1600;
119pub const PAD_STEP: usize = 256;
121pub const BATCH: usize = 500;
123pub const MEDIA_BATCH: u64 = 24 << 20;
125pub const MEDIA_CAP_MB: u64 = 10;
127const DAY_MS: i64 = 86_400_000;
128
129pub fn folder() -> PathBuf {
131 crate::config::data_dir().join(FOLDER)
132}
133
134pub fn private_folder() -> PathBuf {
136 crate::config::state_dir().join(FOLDER)
137}
138
139pub trait Sealer {
141 fn seal(&self, plain: &str) -> String;
143 fn open(&self, sealed: &str) -> Option<String>;
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
151pub struct Part {
152 #[serde(default)]
154 pub seq: i64,
155 #[serde(default)]
157 pub ct: String,
158 #[serde(default, skip_serializing_if = "String::is_empty")]
160 pub name: String,
161 #[serde(default, skip_serializing_if = "is_zero_u64")]
163 pub size: u64,
164 #[serde(default, skip_serializing_if = "String::is_empty")]
166 pub text: String,
167 #[serde(default, skip_serializing_if = "String::is_empty")]
169 pub hash: String,
170 #[serde(default)]
172 pub state: String,
173 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
175 pub fields: BTreeMap<String, Value>,
176}
177
178fn is_zero_u64(value: &u64) -> bool {
179 *value == 0
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Text {
185 pub id: String,
189 pub at: i64,
191 #[serde(default, skip_serializing_if = "String::is_empty")]
193 pub thread: String,
194 #[serde(rename = "box")]
196 pub direction: String,
197 #[serde(default)]
199 pub with: Vec<String>,
200 #[serde(default, skip_serializing_if = "String::is_empty")]
202 pub from: String,
203 #[serde(default, skip_serializing_if = "String::is_empty")]
204 pub body: String,
205 #[serde(default = "no_sim")]
207 pub sub: i32,
208 #[serde(default, skip_serializing_if = "is_false")]
210 pub picture: bool,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub parts: Vec<Part>,
214 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
216 pub fields: BTreeMap<String, Value>,
217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub addrs: Vec<BTreeMap<String, Value>>,
220 #[serde(default, skip_serializing_if = "String::is_empty")]
222 pub key: String,
223 #[serde(default, skip_serializing_if = "is_zero")]
225 pub deleted: i64,
226 #[serde(default, skip_serializing_if = "String::is_empty")]
231 pub moved: String,
232 #[serde(skip)]
234 pub device: String,
235 #[serde(skip)]
238 pub also: Vec<String>,
239}
240
241fn no_sim() -> i32 {
242 -1
243}
244
245fn is_false(value: &bool) -> bool {
246 !*value
247}
248
249fn is_zero(value: &i64) -> bool {
250 *value == 0
251}
252
253impl Text {
254 pub fn line_id(&self) -> String {
256 if self.deleted > 0 { format!("{}#deleted", self.id) } else { self.id.clone() }
257 }
258
259 pub fn media_bytes(&self) -> u64 {
261 self.parts.iter().filter(|p| p.state == "here").map(|p| p.size).sum()
262 }
263
264 pub fn row(&self) -> Option<Row> {
266 Row::of(&self.id, self.at)
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
278pub struct Row {
279 pub mms: bool,
280 pub id: i64,
281 pub date: i64,
282}
283
284impl Row {
285 pub fn name(&self) -> String {
287 format!("{}-{}@{}", if self.mms { "mms" } else { "sms" }, self.id, self.date)
288 }
289
290 pub fn of(name: &str, at: i64) -> Option<Row> {
295 let (table, rest) = name.split_once('-')?;
296 let mms = match table {
297 "sms" => false,
298 "mms" => true,
299 _ => return None,
300 };
301 let (id, date) = match rest.split_once('@') {
302 Some((id, date)) => (id.parse().ok()?, date.parse().ok()?),
303 None => (rest.parse().ok()?, if mms { at.div_euclid(1000) } else { at }),
304 };
305 Some(Row { mms, id, date })
306 }
307
308 pub fn at(&self) -> i64 {
310 if self.mms { self.date.saturating_mul(1000) } else { self.date }
311 }
312}
313
314#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
316pub struct Request {
317 pub key: String,
319 pub phone: String,
321 pub to: String,
323 pub body: String,
324 #[serde(default = "no_sim")]
326 pub sub: i32,
327 pub written: i64,
329 #[serde(default, skip_serializing_if = "is_zero")]
334 pub ahead: i64,
335 #[serde(default, skip_serializing_if = "String::is_empty")]
337 pub again: String,
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub media: Vec<Medium>,
345 #[serde(default, skip_serializing_if = "String::is_empty")]
347 pub words: String,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub also: Vec<String>,
351 #[serde(default, skip_serializing_if = "String::is_empty")]
361 pub writer: String,
362 #[serde(skip)]
364 pub device: String,
365}
366
367impl Request {
368 pub fn multimedia(&self) -> bool {
370 !self.media.is_empty()
371 }
372
373 pub fn said_words(&self) -> &str {
375 if self.multimedia() { &self.words } else { &self.body }
376 }
377}
378
379#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
383pub struct Medium {
384 pub hash: String,
385 pub ct: String,
386 #[serde(default)]
387 pub size: u64,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
395pub struct Limits {
396 pub sub: i32,
398 pub max: u64,
400 #[serde(default)]
402 pub width: u32,
403 #[serde(default)]
404 pub height: u32,
405 #[serde(default)]
407 pub recipients: u32,
408 #[serde(default)]
410 pub group: bool,
411 #[serde(default)]
412 pub mms: bool,
413}
414
415#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
417pub struct Outcome {
418 pub key: String,
419 pub state: String,
422 pub at: i64,
424 #[serde(default, skip_serializing_if = "String::is_empty")]
427 pub reason: String,
428 #[serde(default, skip_serializing_if = "String::is_empty")]
432 pub row: String,
433 #[serde(default, skip_serializing_if = "Vec::is_empty")]
437 pub limits: Vec<Limits>,
438 #[serde(skip)]
440 pub device: String,
441}
442
443pub fn written<T: Serialize>(value: &T, sealer: &dyn Sealer) -> String {
448 let text = serde_json::to_string(value).unwrap_or_default();
449 sealer.seal(&padded(&text))
450}
451
452pub fn padded(json: &str) -> String {
454 let Some(body) = json.strip_suffix('}') else { return json.to_string() };
455 let joint = if body.ends_with('{') { "" } else { "," };
456 let bare = format!("{body}{joint}\"pad\":\"\"}}");
457 let spaces = (PAD_STEP - bare.len() % PAD_STEP) % PAD_STEP;
458 format!("{body}{joint}\"pad\":\"{}\"}}", " ".repeat(spaces))
459}
460
461pub fn file_name(device: &str) -> String {
463 calls::file_safe(device)
464}
465
466pub fn time_of_day(at_ms: i64, now: &Zoned) -> String {
468 calls::local(at_ms, now).strftime("%H:%M").to_string()
469}
470
471pub fn own_file(root: &Path, store: &str, device: &str) -> PathBuf {
473 calls::own_file(root, store, device)
474}
475
476pub fn read_file<T: for<'de> Deserialize<'de>>(path: &Path, sealer: &dyn Sealer) -> Vec<T> {
478 read_lines(&std::fs::read_to_string(path).unwrap_or_default(), sealer)
479}
480
481pub fn read_lines<T: for<'de> Deserialize<'de>>(text: &str, sealer: &dyn Sealer) -> Vec<T> {
483 text.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| sealer.open(l.trim())).filter_map(|plain| serde_json::from_str(&plain).ok()).collect()
484}
485
486fn read_store<T: for<'de> Deserialize<'de>>(root: &Path, store: &str, sealer: &dyn Sealer, mut set: impl FnMut(&mut T, &str)) -> Vec<T> {
488 let mut all = Vec::new();
489 for (device, path) in calls::device_files(&root.join(store)) {
490 for mut value in read_file::<T>(&path, sealer) {
491 set(&mut value, &device);
492 all.push(value);
493 }
494 }
495 all
496}
497
498pub fn read_logs(root: &Path, sealer: &dyn Sealer) -> Vec<Text> {
501 fold_logs(read_store(root, LOG, sealer, |t: &mut Text, d| t.device = d.to_string()))
502}
503
504fn place_of(name: &str, at: i64) -> String {
508 Row::of(name, at).map_or_else(|| name.to_string(), |r| r.name())
509}
510
511pub fn fold_logs(lines: Vec<Text>) -> Vec<Text> {
521 let deletions: BTreeMap<(String, String), i64> = lines.iter().filter(|t| t.deleted > 0).map(|t| ((t.device.clone(), place_of(&t.id, t.at)), t.deleted)).collect();
522 let places: BTreeSet<(String, String)> = lines.iter().filter(|t| t.deleted == 0).map(|t| (t.device.clone(), place_of(&t.id, t.at))).collect();
523 let mut moved: BTreeSet<(String, String)> = BTreeSet::new();
524 let mut old_names: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
525 for t in lines.iter().filter(|t| t.deleted > 0 && !t.moved.is_empty()) {
526 let to = (t.device.clone(), place_of(&t.moved, 0));
527 if places.contains(&to) {
528 moved.insert((t.device.clone(), place_of(&t.id, t.at)));
529 old_names.entry(to).or_default().push(format!("{}/{}", t.device, t.id));
530 }
531 }
532 let mut seen = BTreeSet::new();
533 let mut texts: Vec<Text> = lines
534 .into_iter()
535 .filter(|t| t.deleted == 0 && t.at > 0)
536 .filter_map(|t| {
537 let place = (t.device.clone(), place_of(&t.id, t.at));
538 if moved.contains(&place) || !seen.insert(place.clone()) {
539 return None;
540 }
541 let deleted = deletions.get(&place).copied().unwrap_or(0);
542 let mut also = t.also.clone();
543 also.extend(old_names.get(&place).into_iter().flatten().cloned());
544 Some(Text { deleted, also, ..t })
545 })
546 .collect();
547 texts.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
548 texts
549}
550
551#[derive(Debug, Default)]
555struct LogRead {
556 lines: Vec<Text>,
558 plain: BTreeMap<String, Print>,
559 upto: u64,
560 before: Vec<u8>,
561 seen: Stamp,
562}
563
564#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
574struct Stamp {
575 len: u64,
576 modified: Option<std::time::SystemTime>,
577 inode: u64,
578 changed: i128,
579}
580
581impl Stamp {
582 fn of(meta: &std::fs::Metadata) -> Stamp {
583 #[cfg(unix)]
584 {
585 use std::os::unix::fs::MetadataExt;
586 Stamp { len: meta.len(), modified: meta.modified().ok(), inode: meta.ino(), changed: i128::from(meta.ctime()) * 1_000_000_000 + i128::from(meta.ctime_nsec()) }
587 }
588 #[cfg(not(unix))]
589 {
590 Stamp { len: meta.len(), modified: meta.modified().ok(), inode: 0, changed: 0 }
591 }
592 }
593
594 fn partial(len: u64) -> Stamp {
596 Stamp { len, ..Stamp::default() }
597 }
598}
599
600const LOG_CHECK: u64 = 64;
602
603#[derive(Debug, Default)]
611pub struct Logs {
612 files: BTreeMap<String, (PathBuf, LogRead)>,
613 prints: BTreeMap<(String, String), String>,
614 region: Option<&'static str>,
615}
616
617impl Logs {
618 pub fn refresh(&mut self, root: &Path, sealer: &dyn Sealer) -> bool {
620 let found = calls::device_files(&root.join(LOG));
621 let mut changed = self.files.len() != found.len() || self.files.keys().zip(&found).any(|(kept, (device, _))| kept != device);
622 self.files.retain(|device, _| found.iter().any(|(d, _)| d == device));
623 for (device, path) in found {
624 let entry = self.files.entry(device.clone()).or_insert_with(|| (path.clone(), LogRead::default()));
625 if entry.0 != path {
626 *entry = (path.clone(), LogRead::default());
627 }
628 changed |= grow(&path, &device, &mut entry.1, sealer);
629 }
630 changed
631 }
632
633 pub fn texts(&self) -> Vec<Text> {
639 fold_logs(self.files.values().flat_map(|(_, read)| read.lines.iter().cloned()).collect())
640 }
641
642 pub fn one_each(&mut self, region: Option<&'static Region>) -> Vec<Text> {
645 let code = region.map(|r| r.code);
646 if code != self.region {
647 self.prints.clear();
648 self.region = code;
649 }
650 let (files, prints) = (&self.files, &mut self.prints);
651 let texts = fold_logs(files.values().flat_map(|(_, read)| read.lines.iter().cloned()).collect());
652 one_each_by(texts, &mut |t: &Text| {
653 prints
654 .entry((t.device.clone(), t.id.clone()))
655 .or_insert_with(|| files.get(&t.device).and_then(|(_, read)| read.plain.get(&t.id)).map_or_else(|| Print::of(t), Clone::clone).key(region))
656 .clone()
657 })
658 }
659}
660
661fn grow(path: &Path, device: &str, read: &mut LogRead, sealer: &dyn Sealer) -> bool {
664 use std::io::{Read, Seek, SeekFrom};
665 let Ok(meta) = std::fs::metadata(path) else {
666 let had = !read.lines.is_empty();
667 *read = LogRead::default();
668 return had;
669 };
670 let seen = Stamp::of(&meta);
671 if seen == read.seen {
672 return false;
673 }
674 let Ok(mut file) = std::fs::File::open(path) else { return false };
675 let from = read.upto - read.before.len() as u64;
677 let mut before = vec![0u8; read.before.len()];
678 let same = seen.len > read.upto && file.seek(SeekFrom::Start(from)).is_ok() && file.read_exact(&mut before).is_ok() && before == read.before;
679 let mut changed = false;
680 if !same {
681 changed = !read.lines.is_empty();
682 *read = LogRead::default();
683 if file.seek(SeekFrom::Start(0)).is_err() {
684 return changed;
685 }
686 }
687 let mut bytes = Vec::new();
688 if file.take(seen.len.saturating_sub(read.upto)).read_to_end(&mut bytes).is_err() {
689 return changed;
690 }
691 let Some(end) = bytes.iter().rposition(|b| *b == b'\n') else {
693 read.seen = Stamp::partial(read.upto);
694 return changed;
695 };
696 let whole = &bytes[..=end];
697 let lines: Vec<Text> = read_lines(&String::from_utf8_lossy(whole), sealer);
698 changed |= !lines.is_empty();
699 for t in lines {
700 if t.deleted == 0 {
701 read.plain.entry(t.id.clone()).or_insert_with(|| Print::of(&t));
702 }
703 read.lines.push(Text { device: device.to_string(), fields: BTreeMap::new(), addrs: Vec::new(), ..t });
704 }
705 let mut tail = std::mem::take(&mut read.before);
706 tail.extend_from_slice(whole);
707 read.before = tail[tail.len().saturating_sub(LOG_CHECK as usize)..].to_vec();
708 read.upto += whole.len() as u64;
709 read.seen = if read.upto == seen.len { seen } else { Stamp::partial(read.upto) };
711 changed
712}
713
714pub fn read_requests(root: &Path, sealer: &dyn Sealer) -> Vec<Request> {
719 let mut all: Vec<Request> = read_store(root, SEND, sealer, |r: &mut Request, d| r.device = d.to_string());
720 all.retain(|r| r.writer.is_empty() || calls::file_safe(&r.writer) == r.device);
721 for r in all.iter_mut().filter(|r| !r.writer.is_empty()) {
722 r.device = r.writer.clone();
723 }
724 all
725}
726
727pub fn write_request(root: &Path, device: &str, request: &Request, sealer: &dyn Sealer) -> Result<usize, String> {
730 let request = Request { writer: device.to_string(), device: String::new(), ..request.clone() };
731 append(&own_file(root, SEND, device), std::slice::from_ref(&request), sealer, None)
732}
733
734pub fn read_outcomes(root: &Path, sealer: &dyn Sealer) -> Vec<Outcome> {
735 let mut all: Vec<Outcome> = read_store(root, OUTCOME, sealer, |o: &mut Outcome, d| o.device = d.to_string());
736 all.sort_by_key(|o| o.at);
737 all
738}
739
740pub fn append<T: Serialize + for<'de> Deserialize<'de>>(own: &Path, values: &[T], sealer: &dyn Sealer, id_of: Option<&dyn Fn(&T) -> String>) -> Result<usize, String> {
744 if values.is_empty() {
745 return Ok(0);
746 }
747 crate::filelock::with_lock(own, || {
748 let mut known: BTreeSet<String> = match id_of {
749 Some(id) => read_file::<T>(own, sealer).iter().map(id).collect(),
750 None => BTreeSet::new(),
751 };
752 let mut text = String::new();
753 let mut count = 0;
754 for value in values {
755 if let Some(id) = id_of
756 && !known.insert(id(value))
757 {
758 continue;
759 }
760 text.push_str(&written(value, sealer));
761 text.push('\n');
762 count += 1;
763 }
764 if count > 0 {
765 calls::append_private(own, &text)?;
766 }
767 Ok(count)
768 })
769}
770
771fn time_of(sealer: &dyn Sealer, line: &str) -> Option<i64> {
773 let value: Value = serde_json::from_str(&sealer.open(line.trim())?).ok()?;
774 value["at"].as_i64().or_else(|| value["written"].as_i64())
775}
776
777pub fn trim_own(root: &Path, device: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
780 let mut gone = 0;
781 for store in [SEND, OUTCOME] {
782 gone += calls::rewrite_lines(&own_file(root, store, device), |line| time_of(sealer, line).is_some_and(|at| now_ms - at > KEPT_DAYS * DAY_MS))?;
783 }
784 Ok(gone)
785}
786
787pub fn trim_others(root: &Path, here: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
789 let mut gone = 0;
790 for store in [SEND, OUTCOME] {
791 for (device, path) in calls::device_files(&root.join(store)) {
792 if device != calls::file_safe(here) {
793 gone += calls::rewrite_lines(&path, |line| time_of(sealer, line).is_some_and(|at| now_ms - at > (KEPT_DAYS + LATE_DAYS) * DAY_MS))?;
794 }
795 }
796 }
797 Ok(gone)
798}
799
800pub fn kept(texts: &[Text], media_here: &dyn Fn(&str) -> bool) -> (usize, u64) {
802 let mut hashes = BTreeSet::new();
803 let bytes = texts.iter().flat_map(|t| t.parts.iter()).filter(|p| p.state == "here" && !p.hash.is_empty() && media_here(&p.hash) && hashes.insert(p.hash.clone())).map(|p| p.size).sum();
804 (texts.len(), bytes)
805}
806
807#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
814pub struct TextMark {
815 pub at: i64,
817 pub conversation: String,
819 #[serde(default, skip_serializing_if = "is_zero")]
820 pub read: i64,
821 #[serde(default, skip_serializing_if = "is_zero")]
822 pub hidden: i64,
823}
824
825#[derive(Debug, Clone, Default, PartialEq, Eq)]
827pub struct Marks {
828 pub read: BTreeMap<String, i64>,
829 pub hidden: BTreeMap<String, i64>,
830}
831
832impl Marks {
833 pub fn read_all(root: &Path, sealer: &dyn Sealer) -> Marks {
835 let mut marks = Marks::default();
836 for (_, path) in calls::device_files(&root.join(MARKS)) {
837 for m in read_file::<TextMark>(&path, sealer) {
838 marks.note(&m);
839 }
840 }
841 marks
842 }
843
844 fn note(&mut self, m: &TextMark) {
845 for (moment, by) in [(m.read, &mut self.read), (m.hidden, &mut self.hidden)] {
846 if moment > 0 {
847 let kept = by.entry(m.conversation.clone()).or_insert(0);
848 *kept = (*kept).max(moment);
849 }
850 }
851 }
852
853 pub fn hides(&self, t: &Text) -> bool {
855 self.hidden.get(&conversation_id(&t.with)).is_some_and(|upto| t.at <= *upto)
856 }
857}
858
859pub fn write_mark(root: &Path, device: &str, mark: &TextMark, sealer: &dyn Sealer) -> Result<usize, String> {
861 append(&own_file(root, MARKS, device), std::slice::from_ref(mark), sealer, None)
862}
863
864pub fn visible(texts: &[Text], marks: &Marks) -> Vec<Text> {
866 texts.iter().filter(|t| !marks.hides(t)).cloned().collect()
867}
868
869pub fn unread<'a>(texts: &'a [Text], marks: &Marks, now_ms: i64) -> BTreeMap<String, Vec<&'a Text>> {
872 let mut by: BTreeMap<String, Vec<&Text>> = BTreeMap::new();
873 for t in texts.iter().filter(|t| t.direction == "in" && t.deleted == 0 && now_ms - t.at <= UNREAD_DAYS * DAY_MS && !marks.hides(t)) {
874 let id = conversation_id(&t.with);
875 if id.is_empty() || marks.read.get(&id).is_some_and(|upto| t.at <= *upto) {
876 continue;
877 }
878 by.entry(id).or_default().push(t);
879 }
880 for list in by.values_mut() {
881 list.sort_by(|a, b| b.at.cmp(&a.at));
882 }
883 by
884}
885
886pub fn drop_hidden(own_log: &Path, marks: &Marks, sealer: &dyn Sealer) -> Result<usize, String> {
890 if marks.hidden.is_empty() {
891 return Ok(0);
892 }
893 calls::rewrite_lines(own_log, |line| sealer.open(line.trim()).and_then(|plain| serde_json::from_str::<Text>(&plain).ok()).is_some_and(|t| marks.hides(&t)))
894}
895
896pub fn folded_into(key: &str, at_ms: i64, texts: &[Text]) -> Option<String> {
900 if key.is_empty() {
901 return None;
902 }
903 texts.iter().find(|t| t.direction == "in" && t.with.len() == 1 && t.with[0] == key && (t.at - at_ms).abs() <= FOLD_MS).map(|t| conversation_id(&t.with))
904}
905
906pub const OWN_ADDRESS: &str = "insert-address-token";
910pub const ADDR_FROM: i64 = 137;
912pub const ADDR_TO: i64 = 151;
913pub const ADDR_CC: i64 = 130;
914pub const ADDR_BCC: i64 = 129;
915
916#[derive(Debug, Clone, Default, PartialEq, Eq)]
922pub struct Print {
923 pub mms: bool,
925 pub outgoing: bool,
926 pub second: i64,
928 pub people: Vec<String>,
932 pub words: Vec<String>,
934 pub media: Vec<String>,
936}
937
938pub fn print_people(outgoing: bool, kind: i64) -> bool {
941 if outgoing { matches!(kind, ADDR_TO | ADDR_CC | ADDR_BCC) } else { kind == ADDR_FROM }
942}
943
944pub fn is_words(ct: &str) -> bool {
946 ct.starts_with("text/plain") || ct == "application/smil"
947}
948
949pub fn carried(words: &str) -> std::borrow::Cow<'_, str> {
954 if words.chars().any(|c| !xml_char(c)) { std::borrow::Cow::Owned(words.chars().filter(|c| xml_char(*c)).collect()) } else { std::borrow::Cow::Borrowed(words) }
955}
956
957pub fn xml_char(c: char) -> bool {
959 matches!(c, '\t' | '\n' | '\r') || (c >= ' ' && c != '\u{fffe}' && c != '\u{ffff}')
960}
961
962impl Print {
963 pub fn of(t: &Text) -> Print {
965 let mms = t.id.starts_with("mms-");
966 let outgoing = t.direction == "out";
967 let field = |name: &str| t.fields.get(name).and_then(Value::as_str).unwrap_or_default().to_string();
968 let (people, words, media) = if mms {
969 let people: Vec<String> = t.addrs.iter().filter(|a| print_people(outgoing, number(a.get("type")))).filter_map(|a| a.get("address").and_then(Value::as_str)).filter(|a| !a.trim().is_empty() && *a != OWN_ADDRESS).map(str::to_string).collect();
970 let people = if people.is_empty() { t.with.clone() } else { people };
971 let words = t.parts.iter().filter(|p| p.ct.starts_with("text/plain")).map(|p| p.text.clone()).collect();
972 let media = t.parts.iter().filter(|p| !is_words(&p.ct)).map(|p| p.ct.clone()).collect();
973 (people, words, media)
974 } else {
975 let address = field("address");
976 let people = if address.trim().is_empty() { t.with.clone() } else { vec![address] };
977 (people, vec![t.body.clone()], Vec::new())
978 };
979 Print { mms, outgoing, second: t.at.div_euclid(1000), people, words, media }
980 }
981
982 pub fn key(&self, region: Option<&Region>) -> String {
988 use sha2::{Digest, Sha256};
989 let mut people: Vec<String> = self.people.iter().flat_map(|p| p.split('~')).map(|p| phones::key(p, region)).filter(|k| !k.is_empty()).collect();
990 people.sort_unstable();
991 people.dedup();
992 let mut words: Vec<String> = self.words.iter().map(|w| carried(w).into_owned()).filter(|w| !w.is_empty() && !(self.mms && w == "null")).collect();
993 words.sort_unstable();
994 let mut media: Vec<String> = self.media.iter().map(|m| m.trim().to_ascii_lowercase()).collect();
995 media.sort_unstable();
996 let mut hasher = Sha256::new();
997 for piece in [if self.mms { "mms" } else { "sms" }, if self.outgoing { "out" } else { "in" }, &self.second.to_string(), &people.join("\u{1f}"), &words.join("\u{1e}"), &media.join("\u{1f}")] {
998 hasher.update(piece.as_bytes());
999 hasher.update([0u8]);
1000 }
1001 hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
1002 }
1003}
1004
1005pub const MOVED_WITHIN_MS: i64 = 10 * 60_000;
1009
1010pub fn moved(old: &Text, new: &Text) -> bool {
1020 let (Some(a), Some(b)) = (old.row(), new.row()) else { return false };
1021 if a.mms != b.mms || a.id != b.id || a.date == b.date || old.direction != new.direction {
1022 return false;
1023 }
1024 let (was, is) = (Print::of(old), Print::of(new));
1025 let people = |p: &Print| {
1026 let mut people: Vec<String> = p.people.iter().map(|w| w.trim().to_string()).collect();
1027 people.sort_unstable();
1028 people.dedup();
1029 people
1030 };
1031 if people(&was) != people(&is) {
1032 return false;
1033 }
1034 if a.mms {
1035 let hashes = |t: &Text| t.parts.iter().filter(|p| !p.hash.is_empty()).map(|p| p.hash.clone()).collect::<BTreeSet<String>>();
1036 hashes(old).is_subset(&hashes(new)) && was.words.iter().all(|w| is.words.contains(w)) && was.media.iter().all(|m| is.media.contains(m))
1037 } else {
1038 old.body == new.body && (old.direction == "out" || (b.date - a.date).abs() <= MOVED_WITHIN_MS)
1039 }
1040}
1041
1042pub fn moved_marks(log: &[Text], listed: &BTreeSet<Row>, now: i64) -> Vec<Text> {
1048 let marked: BTreeSet<&str> = log.iter().filter(|t| t.deleted > 0).map(|t| t.id.as_str()).collect();
1049 let mut by_id: BTreeMap<(bool, i64), Vec<(&Text, Row)>> = BTreeMap::new();
1050 for t in log.iter().filter(|t| t.deleted == 0) {
1051 if let Some(row) = t.row().filter(|r| listed.contains(r)) {
1052 by_id.entry((row.mms, row.id)).or_default().push((t, row));
1053 }
1054 }
1055 log.iter()
1056 .filter(|t| t.deleted == 0 && !marked.contains(t.id.as_str()))
1057 .filter_map(|old| {
1058 let row = old.row().filter(|r| !listed.contains(r))?;
1059 let new = by_id.get(&(row.mms, row.id))?.iter().filter(|(t, _)| moved(old, t)).max_by_key(|(_, r)| r.date).map(|(t, _)| *t)?;
1060 Some(Text { id: old.id.clone(), at: old.at, direction: old.direction.clone(), with: old.with.clone(), deleted: now, moved: new.id.clone(), ..Text::default() })
1061 })
1062 .collect()
1063}
1064
1065pub fn number(value: Option<&Value>) -> i64 {
1067 value.and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))).unwrap_or(0)
1068}
1069
1070pub fn one_each(texts: Vec<Text>, region: Option<&Region>) -> Vec<Text> {
1081 one_each_by(texts, &mut |t: &Text| Print::of(t).key(region))
1082}
1083
1084pub fn one_each_by(texts: Vec<Text>, print: &mut dyn FnMut(&Text) -> String) -> Vec<Text> {
1088 let phones: BTreeSet<&str> = texts.iter().map(|t| t.device.as_str()).collect();
1089 if phones.len() < 2 {
1090 return texts;
1091 }
1092 let mut groups: BTreeMap<String, BTreeMap<String, Vec<usize>>> = BTreeMap::new();
1094 for (i, t) in texts.iter().enumerate() {
1095 groups.entry(print(t)).or_default().entry(t.device.clone()).or_default().push(i);
1096 }
1097 let media = |t: &Text| t.parts.iter().filter(|p| p.state == "here" && !p.hash.is_empty()).count();
1098 let mut slots: Vec<Option<Text>> = texts.into_iter().map(Some).collect();
1099 let mut out: Vec<Text> = Vec::with_capacity(slots.len());
1100 for by_phone in groups.into_values() {
1101 let most = by_phone.values().map(Vec::len).max().unwrap_or(0);
1102 for k in 0..most {
1103 let copies: Vec<usize> = by_phone.values().filter_map(|list| list.get(k).copied()).collect();
1104 let best = copies
1105 .iter()
1106 .copied()
1107 .max_by(|&a, &b| {
1108 let (a, b) = (slots[a].as_ref(), slots[b].as_ref());
1109 let rank = |t: Option<&Text>| t.map(|t| (media(t), t.deleted == 0, !t.key.is_empty()));
1110 rank(a).cmp(&rank(b)).then_with(|| b.map(|t| &t.device).cmp(&a.map(|t| &t.device)))
1112 })
1113 .unwrap_or(copies[0]);
1114 let others: Vec<Text> = copies.iter().filter(|&&i| i != best).filter_map(|&i| slots[i].take()).collect();
1115 let Some(mut kept) = slots[best].take() else { continue };
1116 if others.iter().any(|t| t.deleted == 0) {
1117 kept.deleted = 0;
1118 } else if kept.deleted > 0 {
1119 kept.deleted = others.iter().map(|t| t.deleted).fold(kept.deleted, i64::max);
1120 }
1121 if kept.key.is_empty() {
1122 kept.key = others.iter().map(|t| t.key.clone()).find(|k| !k.is_empty()).unwrap_or_default();
1123 }
1124 kept.also.extend(others.iter().map(|t| format!("{}/{}", t.device, t.id)));
1125 out.push(kept);
1126 }
1127 }
1128 out.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
1129 out
1130}
1131
1132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1136pub struct Claim {
1137 pub key: String,
1138 pub at: i64,
1140 pub state: String,
1144 #[serde(default, skip_serializing_if = "String::is_empty")]
1146 pub to: String,
1147 #[serde(default, skip_serializing_if = "String::is_empty")]
1148 pub hash: String,
1149 #[serde(default, skip_serializing_if = "String::is_empty")]
1152 pub talk: String,
1153 #[serde(default, skip_serializing_if = "is_false")]
1156 pub mms: bool,
1157}
1158
1159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1161pub struct Mark {
1162 pub since: i64,
1163 #[serde(default, skip_serializing_if = "is_zero")]
1169 pub writers: i64,
1170}
1171
1172#[derive(Debug, Clone, Default, PartialEq, Eq)]
1174pub struct Ledger {
1175 pub mark: Mark,
1176 pub claims: Vec<Claim>,
1177 keys: std::collections::BTreeSet<String>,
1180}
1181
1182impl Ledger {
1183 pub fn open(dir: &Path, now_ms: i64) -> Result<Ledger, String> {
1187 let mark_path = dir.join(MARK);
1188 let mark = match std::fs::read_to_string(&mark_path).ok().and_then(|t| serde_json::from_str::<Mark>(&t).ok()) {
1189 Some(mark) if mark.writers > 0 => mark,
1190 Some(older) => {
1192 let mark = Mark { writers: now_ms.max(older.since), ..older };
1193 replace_flushed(&mark_path, &serde_json::to_string(&mark).map_err(|e| e.to_string())?)?;
1194 mark
1195 }
1196 None => {
1197 let mark = Mark { since: now_ms, writers: now_ms };
1198 write_flushed(&mark_path, &serde_json::to_string(&mark).map_err(|e| e.to_string())?, false)?;
1199 mark
1200 }
1201 };
1202 let claims: Vec<Claim> = std::fs::read_to_string(dir.join(LEDGER)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
1203 let keys = claims.iter().map(|c| c.key.clone()).collect();
1204 Ok(Ledger { mark, claims, keys })
1205 }
1206
1207 pub fn has(&self, key: &str) -> bool {
1209 self.keys.contains(key)
1210 }
1211
1212 pub fn with(mark: Mark, claims: Vec<Claim>) -> Ledger {
1214 let keys = claims.iter().map(|c| c.key.clone()).collect();
1215 Ledger { mark, claims, keys }
1216 }
1217
1218 pub fn add(&mut self, claim: Claim) {
1220 self.keys.insert(claim.key.clone());
1221 self.claims.push(claim);
1222 }
1223
1224 pub fn state(&self, key: &str) -> Option<&str> {
1226 self.claims.iter().rev().find(|c| c.key == key).map(|c| c.state.as_str())
1227 }
1228}
1229
1230pub fn record(dir: &Path, claim: &Claim) -> Result<(), String> {
1233 record_all(dir, std::slice::from_ref(claim))
1234}
1235
1236pub fn record_all(dir: &Path, claims: &[Claim]) -> Result<(), String> {
1239 if claims.is_empty() {
1240 return Ok(());
1241 }
1242 let mut text = String::new();
1243 for claim in claims {
1244 text.push_str(&serde_json::to_string(claim).map_err(|e| e.to_string())?);
1245 text.push('\n');
1246 }
1247 write_flushed(&dir.join(LEDGER), &text, true)
1248}
1249
1250fn write_flushed(path: &Path, text: &str, append: bool) -> Result<(), String> {
1252 use std::io::Write;
1253 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1254 if let Some(dir) = path.parent() {
1255 std::fs::create_dir_all(dir).map_err(fail)?;
1256 }
1257 let mut options = std::fs::OpenOptions::new();
1258 options.create(true).write(true);
1259 if append {
1260 options.append(true);
1261 } else {
1262 options.truncate(true);
1263 }
1264 #[cfg(unix)]
1265 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1266 let mut file = options.open(path).map_err(fail)?;
1267 file.write_all(text.as_bytes()).map_err(fail)?;
1268 file.sync_all().map_err(fail)
1269}
1270
1271fn replace_flushed(path: &Path, text: &str) -> Result<(), String> {
1274 let beside = path.with_extension("new");
1275 write_flushed(&beside, text, false)?;
1276 std::fs::rename(&beside, path).map_err(|e| format!("{}: {e}", path.display()))
1277}
1278
1279pub fn trim_ledger(dir: &Path, now_ms: i64) -> Result<usize, String> {
1282 calls::rewrite_lines(&dir.join(LEDGER), |line| serde_json::from_str::<Claim>(line).is_ok_and(|c| now_ms - c.at > LEDGER_KEPT_MS))
1283}
1284
1285pub fn words_hash(body: &str) -> String {
1287 crate::appnotes::talk_key("texts", body)
1288}
1289
1290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1294pub enum Verdict {
1295 Send,
1297 NotMine,
1299 Decided,
1301 BeforeMark,
1303 Expired,
1305 Clocks,
1307 Refused(&'static str),
1310 Wait,
1314}
1315
1316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum Standing {
1323 Counted,
1324 Gone,
1325 Unknown,
1326}
1327
1328pub fn refusal(to: &str, body: &str, region: Option<&Region>) -> Option<&'static str> {
1332 if body.trim().is_empty() {
1333 return Some("empty");
1334 }
1335 if body.chars().count() > WRITE_MAX {
1336 return Some("too-long");
1337 }
1338 if to.contains([',', ';']) || to.chars().filter(char::is_ascii_digit).count() > 15 {
1339 return Some("several");
1340 }
1341 let key = phones::key(to, region);
1342 if !phones::is_whole(&key) || key.trim_start_matches('+').len() < 8 {
1343 return Some("short-number");
1344 }
1345 None
1346}
1347
1348pub fn refusal_mms(to: &str, words: &str, media: &[Medium], also: &[String], region: Option<&Region>) -> Option<&'static str> {
1355 if words.chars().count() > WRITE_MAX {
1356 return Some("too-long");
1357 }
1358 if media.is_empty() {
1359 return Some("empty");
1360 }
1361 if media.len() > MEDIA_MAX {
1362 return Some("too-many-media");
1363 }
1364 let hashed = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit());
1365 if media.iter().any(|m| !hashed(&m.hash) || m.size == 0 || !sendable(&m.ct)) {
1366 return Some("media-kind");
1367 }
1368 if !also.is_empty() {
1369 return Some("several");
1370 }
1371 refusal(to, "-", region)
1373}
1374
1375pub fn sendable(ct: &str) -> bool {
1379 let ct = ct.trim().to_ascii_lowercase();
1380 let (top, sub) = ct.split_once('/').unwrap_or(("", ""));
1381 !sub.is_empty() && (matches!(top, "image" | "video" | "audio") || matches!(ct.as_str(), "text/x-vcard" | "text/vcard" | "text/x-vcalendar" | "text/calendar"))
1382}
1383
1384pub fn verdict(request: &Request, here: &str, now_ms: i64, ledger: &Ledger, region: Option<&Region>, standing: &dyn Fn(&str) -> Standing) -> Verdict {
1398 if request.phone != here || request.key.is_empty() {
1399 return Verdict::NotMine;
1400 }
1401 if ledger.has(&request.key) {
1402 return Verdict::Decided;
1403 }
1404 if request.writer.is_empty() && ledger.mark.writers > 0 && request.written - request.ahead >= ledger.mark.writers {
1405 return Verdict::Refused("old-computer");
1406 }
1407 match standing(&request.device) {
1408 Standing::Counted => {}
1409 Standing::Gone => return Verdict::Refused("not-counted"),
1410 Standing::Unknown => {
1411 let written = request.written - request.ahead;
1412 return if written > now_ms + AHEAD_MS {
1413 Verdict::Clocks
1414 } else if now_ms - written > EXPIRY_MS {
1415 Verdict::Expired
1416 } else {
1417 Verdict::Wait
1418 };
1419 }
1420 }
1421 let refused = if request.multimedia() { refusal_mms(&request.to, &request.words, &request.media, &request.also, region) } else { refusal(&request.to, &request.body, region) };
1422 if let Some(why) = refused {
1423 return Verdict::Refused(why);
1424 }
1425 let written = request.written - request.ahead;
1426 if written < ledger.mark.since {
1427 return Verdict::BeforeMark;
1428 }
1429 if written > now_ms + AHEAD_MS {
1430 return Verdict::Clocks;
1431 }
1432 if now_ms - written > EXPIRY_MS {
1433 return Verdict::Expired;
1434 }
1435 Verdict::Send
1436}
1437
1438pub fn doubt_after(claim: &Claim, found: Option<String>, now_ms: i64) -> Option<Outcome> {
1446 let age = now_ms - claim.at;
1447 if age < if claim.mms { LOOK_AFTER_MMS_MS } else { LOOK_AFTER_MS } {
1448 return None;
1449 }
1450 match found {
1451 Some(row) => Some(Outcome { key: claim.key.clone(), state: "sent".into(), at: now_ms, row, ..Outcome::default() }),
1452 None if claim.mms && age < MMS_LAST_WORD_MS => None,
1453 None => Some(Outcome { key: claim.key.clone(), state: "doubt".into(), at: now_ms, ..Outcome::default() }),
1454 }
1455}
1456
1457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1466pub enum Took {
1467 Sent,
1468 Failed(&'static str),
1469 Look,
1470 Doubt(&'static str),
1471}
1472
1473pub fn took(code: i64, conf: Option<&crate::mms::Conf>, http: i64) -> Took {
1485 match (code, conf) {
1486 (-1, Some(conf)) if conf.ok() => Took::Sent,
1487 (-1, None) => Took::Look,
1488 (4, None) if http < 400 => Took::Doubt("network"),
1489 (0, _) => Took::Doubt("cancelled"),
1490 (1, _) => Took::Doubt("unexpected"),
1491 (code, _) if !(-1..=13).contains(&code) => Took::Doubt("unexpected"),
1492 (code, conf) => Took::Failed(mms_failure(code, conf.map(|c| c.status))),
1493 }
1494}
1495
1496pub fn mms_failure(code: i64, status: Option<u8>) -> &'static str {
1505 let carrier = |status: Option<u8>| match status {
1506 Some(0xC0..=0xDF) => "carrier-busy",
1507 Some(0xE1 | 0x82) => "carrier-denied",
1508 Some(0xE5 | 0x87 | 0x88) => "carrier-content",
1509 Some(0xEB) => "carrier-credit",
1510 _ => "carrier-refused",
1511 };
1512 match code {
1513 -1 | 4 => carrier(status),
1514 8 | 11 => "mms-data-off",
1515 3 => "mms-no-network",
1516 2 | 7 | 12 => "mms-settings",
1517 5 => "mms-unreadable",
1518 6 => "mms-retry",
1519 9 | 10 => "no-sim",
1520 13 => "mms-satellite",
1521 _ => "other",
1522 }
1523}
1524
1525pub fn limits_of<'a>(outcomes: &'a [Outcome], phone: &str, now_ms: i64) -> Option<(i64, &'a [Limits])> {
1531 outcomes
1532 .iter()
1533 .filter(|o| o.key.is_empty() && o.state == "limits" && o.device == calls::file_safe(phone) && o.at <= now_ms + LIMITS_AHEAD_MS)
1534 .max_by_key(|o| o.at)
1535 .map(|o| (o.at, o.limits.as_slice()))
1536}
1537
1538pub fn new_key() -> String {
1540 use std::io::Read;
1541 let mut bytes = [0u8; 16];
1542 let read = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes));
1543 if read.is_err() {
1544 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
1545 bytes = (now ^ u128::from(std::process::id()).rotate_left(64)).to_le_bytes();
1546 }
1547 bytes.iter().map(|b| format!("{b:02x}")).collect()
1548}
1549
1550const GSM_BASIC: &str = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
1554const GSM_EXTENDED: &str = "^{}\\[~]|€\u{c}";
1556
1557pub fn parts(body: &str) -> (usize, bool) {
1561 let gsm = body.chars().all(|c| GSM_BASIC.contains(c) || GSM_EXTENDED.contains(c));
1562 if gsm {
1563 let septets: usize = body.chars().map(|c| if GSM_EXTENDED.contains(c) { 2 } else { 1 }).sum();
1564 (if septets <= 160 { usize::from(septets > 0) } else { septets.div_ceil(153) }, true)
1565 } else {
1566 let units: usize = body.chars().map(char::len_utf16).sum();
1567 (if units <= 70 { 1 } else { units.div_ceil(67) }, false)
1568 }
1569}
1570
1571#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1575pub struct Said {
1576 pub state: String,
1578 pub text: String,
1580 pub again: bool,
1582}
1583
1584pub fn state_of<'a>(key: &str, outcomes: &'a [Outcome]) -> Option<&'a Outcome> {
1586 let rank = |s: &str| match s {
1587 "taken" => 1,
1588 "handed" => 2,
1589 "doubt" => 3,
1590 "sent" => 4,
1591 "delivered" => 5,
1592 "failed" | "expired" | "refused" | "clocks" => 6,
1593 _ => 0,
1594 };
1595 outcomes.iter().filter(|o| o.key == key && !o.key.is_empty()).max_by_key(|o| (rank(&o.state), o.at))
1597}
1598
1599pub fn said(outcome: Option<&Outcome>, phone_shared: Option<i64>, tr: &Translator, now: &Zoned, media: bool) -> Said {
1605 let time = |ms: i64| calls::local(ms, now).strftime("%H:%M").to_string();
1606 let with = |id: &str, key: &str, value: String| {
1607 let mut args = crate::i18n::args();
1608 args.set(key, value);
1609 tr.text(id, Some(&args))
1610 };
1611 let (state, text, again) = match outcome {
1612 None => match phone_shared {
1613 Some(at) => ("waiting", with("texts-waiting-since", "time", time(at)), false),
1614 None => ("waiting", tr.text("texts-waiting", None), false),
1615 },
1616 Some(o) => match o.state.as_str() {
1617 "taken" | "handed" if media && now.timestamp().as_millisecond() - o.at >= MMS_LAST_WORD_MS => match phone_shared {
1620 Some(at) => ("waiting", with("texts-waiting-since", "time", time(at)), false),
1621 None => ("waiting", tr.text("texts-waiting", None), false),
1622 },
1623 "taken" | "handed" if media && now.timestamp().as_millisecond() - o.at >= LOOK_AFTER_MMS_MS => ("sending", with("texts-sending-still", "time", time(o.at + MMS_LAST_WORD_MS)), false),
1624 "taken" | "handed" => ("sending", tr.text("texts-sending", None), false),
1625 "sent" => ("sent", with("texts-sent-at", "time", time(o.at)), false),
1626 "delivered" => ("delivered", with("texts-delivered-at", "time", time(o.at)), false),
1627 "failed" => ("failed", with("texts-failed", "why", tr.text(&format!("texts-why-{}", known_reason(&o.reason)), None)), true),
1628 "expired" if o.reason == "media" => ("expired", tr.text("texts-expired-media", None), true),
1629 "expired" => ("expired", tr.text("texts-expired", None), true),
1630 "refused" => {
1631 let why = if media && o.reason == "empty" { "old-phone" } else { known_refusal(&o.reason) };
1632 ("refused", tr.text(&format!("texts-refused-{why}"), None), matches!(why, "before-mark" | "old-phone" | "old-computer"))
1633 }
1634 "clocks" => ("clocks", tr.text("texts-clocks", None), true),
1635 "doubt" if matches!(o.reason.as_str(), "network" | "answer") => ("doubt", tr.text(&format!("texts-doubt-{}", o.reason), None), false),
1638 "doubt" if matches!(o.reason.as_str(), "cancelled" | "unexpected") => ("doubt", tr.text(&format!("texts-doubt-{}", o.reason), None), true),
1639 _ => ("doubt", tr.text("texts-doubt", None), true),
1640 },
1641 };
1642 Said { state: state.into(), text, again }
1643}
1644
1645fn known_reason(reason: &str) -> &'static str {
1647 match reason {
1648 "radio-off" => "radio-off",
1649 "no-service" => "no-service",
1650 "limit" => "limit",
1651 "no-sim" => "no-sim",
1652 "mms-data-off" => "mms-data-off",
1653 "mms-no-network" => "mms-no-network",
1654 "mms-settings" => "mms-settings",
1655 "mms-unreadable" => "mms-unreadable",
1656 "mms-retry" => "mms-retry",
1657 "mms-satellite" => "mms-satellite",
1658 "carrier-refused" => "carrier-refused",
1659 "carrier-content" => "carrier-content",
1660 "carrier-denied" => "carrier-denied",
1661 "carrier-credit" => "carrier-credit",
1662 "carrier-busy" => "carrier-busy",
1663 _ => "other",
1664 }
1665}
1666
1667fn known_refusal(reason: &str) -> &'static str {
1669 match reason {
1670 "short-number" => "short-number",
1671 "several" => "several",
1672 "empty" => "empty",
1673 "too-long" => "too-long",
1674 "before-mark" => "before-mark",
1675 "too-large" => "too-large",
1676 "too-many-media" => "too-many-media",
1677 "media-kind" => "media-kind",
1678 "no-mms" => "no-mms",
1679 "not-counted" => "not-counted",
1680 "old-computer" => "old-computer",
1681 _ => "other",
1682 }
1683}
1684
1685#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1687pub struct Conversation {
1688 pub id: String,
1690 pub with: Vec<String>,
1691 pub title: String,
1693 pub last: String,
1695 pub when: String,
1696 pub at: i64,
1697 pub group: bool,
1698}
1699
1700#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1702pub struct Shown {
1703 pub kind: String,
1705 pub name: String,
1706 pub said: String,
1708 pub hash: String,
1710 pub ct: String,
1711}
1712
1713#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1715pub struct Message {
1716 pub id: String,
1717 pub at: i64,
1718 pub when: String,
1720 pub day: String,
1723 pub time: String,
1724 pub direction: String,
1726 pub from: String,
1728 pub body: String,
1729 pub picture: bool,
1730 pub parts: Vec<Shown>,
1731 pub deleted: bool,
1733 pub key: String,
1735 pub said: Option<Said>,
1736 pub to: String,
1738}
1739
1740pub struct Viewer<'a> {
1742 pub now: &'a Zoned,
1743 pub tr: &'a Translator,
1744 pub region: Option<&'static Region>,
1745 pub name_of: &'a dyn Fn(&str) -> Option<String>,
1747 pub phone_shared: Option<i64>,
1749 pub media_here: &'a dyn Fn(&str) -> bool,
1751}
1752
1753pub fn conversation_id(with: &[String]) -> String {
1755 let mut keys: Vec<&str> = with.iter().map(String::as_str).filter(|k| !k.is_empty()).collect();
1756 keys.sort_unstable();
1757 keys.dedup();
1758 keys.join(",")
1759}
1760
1761fn title_of(with: &[String], v: &Viewer) -> String {
1763 with.iter().map(|k| (v.name_of)(k).unwrap_or_else(|| calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ")
1764}
1765
1766fn day_and_time(at: i64, v: &Viewer) -> (String, String) {
1769 let local = calls::local(at, v.now);
1770 let mut day = calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date()));
1771 if local.year() != v.now.year() && local.date() < v.now.date().yesterday().unwrap_or(local.date()) {
1772 day = format!("{day} {}", local.year());
1773 }
1774 (day, local.strftime("%H:%M").to_string())
1775}
1776
1777fn when_words(at: i64, v: &Viewer) -> String {
1779 let local = calls::local(at, v.now);
1780 let time = local.strftime("%H:%M").to_string();
1781 if local.date() == v.now.date() {
1782 time
1783 } else {
1784 format!("{} {time}", calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date())))
1785 }
1786}
1787
1788fn kind_of(ct: &str) -> &'static str {
1790 match ct.split('/').next().unwrap_or("") {
1791 "image" => "picture",
1792 "audio" => "sound",
1793 "video" => "video",
1794 "text" if ct.contains("vcard") || ct.contains("x-vcard") => "card",
1795 "text" => "text",
1796 _ if ct.contains("vcard") => "card",
1797 _ => "file",
1798 }
1799}
1800
1801fn shown(part: &Part, v: &Viewer) -> Option<Shown> {
1805 if part.ct == "application/smil" || part.state == "text" || (kind_of(&part.ct) == "text" && part.hash.is_empty()) {
1806 return None;
1807 }
1808 let kind = kind_of(&part.ct);
1809 let size = crate::view::size(v.tr, usize::try_from(part.size).unwrap_or(usize::MAX));
1810 let mut args = crate::i18n::args();
1811 args.set("size", size);
1812 let (said, hash) = match part.state.as_str() {
1813 "too-big" => (v.tr.text("texts-part-too-big", Some(&args)), String::new()),
1814 "not-downloaded" => (v.tr.text("texts-part-not-downloaded", None), String::new()),
1815 _ if !(v.media_here)(&part.hash) => (v.tr.text("texts-part-coming", Some(&args)), String::new()),
1816 _ => (v.tr.text(&format!("texts-part-{kind}"), Some(&args)), part.hash.clone()),
1817 };
1818 Some(Shown { kind: kind.into(), name: part.name.clone(), said, hash, ct: part.ct.clone() })
1819}
1820
1821pub fn conversations(texts: &[Text], requests: &[Request], v: &Viewer) -> Vec<Conversation> {
1823 let mut by: BTreeMap<String, Conversation> = BTreeMap::new();
1824 let mut add = |with: Vec<String>, at: i64, last: String| {
1825 let id = conversation_id(&with);
1826 if id.is_empty() {
1827 return;
1828 }
1829 let entry = by.entry(id.clone()).or_insert_with(|| Conversation { id: id.clone(), group: with.len() > 1, title: title_of(&with, v), with, last: String::new(), when: String::new(), at: 0 });
1830 if at >= entry.at {
1831 entry.at = at;
1832 entry.last = last;
1833 entry.when = when_words(at, v);
1834 }
1835 };
1836 for t in texts {
1837 let first: String = if t.body.is_empty() && (t.picture || !t.parts.is_empty()) { v.tr.text("phonemsgs-picture", None) } else { t.body.chars().take(80).collect() };
1838 add(t.with.clone(), t.at, first);
1839 }
1840 for r in requests {
1841 let first: String = if r.said_words().is_empty() && r.multimedia() { v.tr.text("phonemsgs-picture", None) } else { r.said_words().chars().take(80).collect() };
1842 add(vec![phones::key(&r.to, v.region)], r.written, first);
1843 }
1844 let mut list: Vec<Conversation> = by.into_values().collect();
1845 list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
1846 list
1847}
1848
1849fn row_key(t: &Text, key_of_row: &BTreeMap<String, Vec<(&str, &str, i64, i64)>>) -> String {
1860 let own = (t.device.as_str(), t.id.as_str());
1861 std::iter::once(own)
1862 .chain(t.also.iter().filter_map(|copy| copy.split_once('/')))
1863 .find_map(|(phone, id)| {
1864 let row = Row::of(id, t.at)?;
1865 let bare = format!("{}-{}", if row.mms { "mms" } else { "sms" }, row.id);
1866 [row.name(), bare].iter().find_map(|name| key_of_row.get(name)?.iter().find(|(device, _, from, until)| (device.is_empty() || *device == phone) && t.at >= *from && t.at <= *until).map(|(_, key, _, _)| key.to_string()))
1867 })
1868 .unwrap_or_default()
1869}
1870
1871pub fn messages(id: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer) -> Vec<Message> {
1876 let from: BTreeMap<&str, i64> = requests.iter().map(|r| (r.key.as_str(), r.written - r.ahead - AHEAD_MS)).collect();
1879 let mut key_of_row: BTreeMap<String, Vec<(&str, &str, i64, i64)>> = BTreeMap::new();
1880 for o in outcomes.iter().filter(|o| !o.row.is_empty()) {
1881 let named = o.row.contains('@');
1882 let (since, until) = if named { (i64::MIN, i64::MAX) } else { (from.get(o.key.as_str()).copied().unwrap_or(i64::MIN), o.at.saturating_add(AHEAD_MS)) };
1883 let name = if named { Row::of(&o.row, 0).map_or_else(|| o.row.clone(), |r| r.name()) } else { o.row.clone() };
1884 key_of_row.entry(name).or_default().push((o.device.as_str(), o.key.as_str(), since, until));
1885 }
1886 let mut out: Vec<Message> = Vec::new();
1887 let mut shown_keys = BTreeSet::new();
1888 for t in texts.iter().filter(|t| conversation_id(&t.with) == id) {
1889 let key = if t.key.is_empty() { row_key(t, &key_of_row) } else { t.key.clone() };
1890 let said = requests.iter().find(|r| !key.is_empty() && r.key == key).map(|r| self::said(state_of(&key, outcomes), v.phone_shared, v.tr, v.now, r.multimedia()));
1891 if !key.is_empty() {
1892 shown_keys.insert(key.clone());
1893 }
1894 let (day, time) = day_and_time(t.at, v);
1895 out.push(Message {
1896 id: format!("{}/{}", t.device, t.id),
1897 at: t.at,
1898 when: when_words(t.at, v),
1899 day,
1900 time,
1901 direction: t.direction.clone(),
1902 from: if t.with.len() > 1 && t.direction == "in" && !t.from.is_empty() { (v.name_of)(&t.from).unwrap_or_else(|| calls::shown_number(&t.from, v.region)) } else { String::new() },
1903 body: t.body.clone(),
1904 picture: t.picture,
1905 parts: t.parts.iter().filter_map(|p| shown(p, v)).collect(),
1906 deleted: t.deleted > 0,
1907 key,
1908 said,
1909 to: String::new(),
1910 });
1911 }
1912 for r in requests.iter().filter(|r| conversation_id(&[phones::key(&r.to, v.region)]) == id && !shown_keys.contains(&r.key)) {
1913 let outcome = state_of(&r.key, outcomes);
1914 let (day, time) = day_and_time(r.written, v);
1915 out.push(Message {
1916 id: format!("request/{}", r.key),
1917 at: r.written,
1918 when: when_words(r.written, v),
1919 day,
1920 time,
1921 direction: "out".into(),
1922 from: String::new(),
1923 body: r.said_words().to_string(),
1924 picture: r.media.iter().any(|m| kind_of(&m.ct) == "picture"),
1925 parts: r.media.iter().filter_map(|m| shown(&Part { ct: m.ct.clone(), size: m.size, hash: m.hash.clone(), state: "here".into(), ..Part::default() }, v)).collect(),
1927 deleted: false,
1928 key: r.key.clone(),
1929 said: Some(said(outcome, v.phone_shared, v.tr, v.now, r.multimedia())),
1930 to: r.to.clone(),
1931 });
1932 }
1933 out.sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id)));
1934 out
1935}
1936
1937#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1939pub struct Found {
1940 pub conversation: String,
1942 pub title: String,
1943 pub message: Message,
1944}
1945
1946pub fn search(query: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer, limit: usize) -> Vec<Found> {
1950 let words: Vec<String> = crate::rules::fold(query).split(' ').filter(|w| !w.is_empty()).map(str::to_string).collect();
1951 if words.is_empty() {
1952 return Vec::new();
1953 }
1954 let mut people: BTreeMap<String, (String, String)> = BTreeMap::new();
1956 let mut found: Vec<&Text> = texts
1957 .iter()
1958 .filter(|t| {
1959 let id = conversation_id(&t.with);
1960 let (_, said) = people.entry(id).or_insert_with(|| {
1961 let title = title_of(&t.with, v);
1962 let numbers = t.with.iter().map(|k| format!("{k} {}", calls::shown_number(k, v.region))).collect::<Vec<_>>().join(" ");
1963 let said = crate::rules::fold(&format!("{title} {numbers}"));
1964 (title, said)
1965 });
1966 let own = crate::rules::fold(&std::iter::once(t.body.as_str()).chain(t.parts.iter().map(|p| p.name.as_str())).collect::<Vec<_>>().join(" "));
1967 words.iter().all(|w| own.contains(w.as_str()) || said.contains(w.as_str()))
1968 })
1969 .collect();
1970 found.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
1971 found.truncate(limit);
1972 found
1973 .into_iter()
1974 .filter_map(|t| {
1975 let id = conversation_id(&t.with);
1976 let row = format!("{}/{}", t.device, t.id);
1977 let message = messages(&id, std::slice::from_ref(t), requests, outcomes, v).into_iter().find(|m| m.id == row)?;
1978 Some(Found { title: people.get(&id).map(|(title, _)| title.clone()).unwrap_or_default(), conversation: id, message })
1979 })
1980 .collect()
1981}
1982
1983pub fn last_sim(id: &str, phone: &str, texts: &[Text]) -> i32 {
1985 texts.iter().rev().find(|t| t.device == calls::file_safe(phone) && conversation_id(&t.with) == id && t.sub >= 0).map_or(-1, |t| t.sub)
1986}
1987
1988pub const PART_KINDS: [&str; 6] = ["picture", "sound", "video", "card", "file", "text"];
1990
1991#[cfg(test)]
1992mod tests {
1993 use super::*;
1994
1995 #[test]
1999 fn marks_read_hide_and_fold() {
2000 let now = 1_792_000_000_000;
2001 let text = |id: &str, with: &str, at: i64, direction: &str| Text { id: id.into(), at, direction: direction.into(), with: vec![with.into()], body: "Votre code 123".into(), ..Text::default() };
2002 let texts = vec![text("sms-1@1", "+33600000001", now - 60_000, "in"), text("sms-2@2", "+33600000001", now - 30_000, "in"), text("sms-3@3", "+33600000002", now - 9 * DAY_MS, "in"), text("sms-4@4", "+33600000003", now - 10_000, "out")];
2003 let none = Marks::default();
2004 let unread = unread(&texts, &none, now);
2005 assert_eq!(unread.keys().cloned().collect::<Vec<_>>(), ["+33600000001"], "in, recent, unread; an old one and one sent are not");
2006 assert_eq!(unread["+33600000001"].iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["sms-2@2", "sms-1@1"]);
2007 let mut marks = Marks::default();
2008 marks.note(&TextMark { at: now, conversation: "+33600000001".into(), read: now - 30_000, ..TextMark::default() });
2009 assert!(super::unread(&texts, &marks, now).is_empty(), "read once opened");
2010 marks.note(&TextMark { at: now, conversation: "+33600000001".into(), hidden: now - 45_000, ..TextMark::default() });
2011 assert_eq!(visible(&texts, &marks).iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["sms-2@2", "sms-3@3", "sms-4@4"], "hidden until then; a newer one shows");
2012 assert_eq!(folded_into("+33600000001", now - 60_000 + 90_000, &texts).as_deref(), Some("+33600000001"), "the same number, within three minutes");
2013 assert_eq!(folded_into("+33600000001", now + 10 * 60_000, &texts), None, "later: another message");
2014 assert_eq!(folded_into("+33600000009", now - 60_000, &texts), None, "another number");
2015 }
2016
2017 #[test]
2020 fn a_hidden_conversation_leaves_the_phones_log() {
2021 struct Plain;
2022 impl Sealer for Plain {
2023 fn seal(&self, plain: &str) -> String {
2024 plain.to_string()
2025 }
2026 fn open(&self, sealed: &str) -> Option<String> {
2027 Some(sealed.to_string())
2028 }
2029 }
2030 let dir = std::env::temp_dir().join(format!("sioul-texts-hidden-{}", std::process::id()));
2031 let log = dir.join("log").join("phone.jsonl");
2032 std::fs::create_dir_all(log.parent().unwrap()).unwrap();
2033 let text = |id: &str, with: &str, at: i64| Text { id: id.into(), at, direction: "in".into(), with: vec![with.into()], ..Text::default() };
2034 append(&log, &[text("sms-1@1", "+33600000001", 1_000), text("sms-2@2", "+33600000002", 2_000), text("sms-3@3", "+33600000001", 5_000)], &Plain, None).unwrap();
2035 let mut marks = Marks::default();
2036 marks.note(&TextMark { at: 4_000, conversation: "+33600000001".into(), hidden: 4_000, ..TextMark::default() });
2037 assert_eq!(drop_hidden(&log, &marks, &Plain).unwrap(), 1);
2038 let left: Vec<String> = read_file::<Text>(&log, &Plain).into_iter().map(|t| t.id).collect();
2039 assert_eq!(left, ["sms-2@2", "sms-3@3"], "the hidden one gone, a later one and another's stay");
2040 let _ = std::fs::remove_dir_all(&dir);
2041 }
2042
2043 struct Stand;
2045
2046 impl Sealer for Stand {
2047 fn seal(&self, plain: &str) -> String {
2048 format!("sealed:{}", plain.bytes().map(|b| format!("{:02x}", b ^ 0x5a)).collect::<String>())
2049 }
2050 fn open(&self, sealed: &str) -> Option<String> {
2051 let hex = sealed.strip_prefix("sealed:")?;
2052 let bytes: Option<Vec<u8>> = (0..hex.len()).step_by(2).map(|i| hex.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|b| b ^ 0x5a)).collect();
2053 String::from_utf8(bytes?).ok()
2054 }
2055 }
2056
2057 const NOW: i64 = 1_791_446_400_000;
2059 const MINUTE: i64 = 60_000;
2060
2061 fn fr() -> Option<&'static Region> {
2062 phones::region_named("FR")
2063 }
2064
2065 fn scratch(name: &str) -> PathBuf {
2066 let dir = std::env::temp_dir().join(format!("sioul-texts-{name}-{}", std::process::id()));
2067 let _ = std::fs::remove_dir_all(&dir);
2068 dir
2069 }
2070
2071 fn request(key: &str, phone: &str, written: i64) -> Request {
2072 Request { key: key.into(), phone: phone.into(), to: "01 99 00 12 34".into(), body: "J'arrive dans dix minutes.".into(), sub: -1, written, writer: "desk".into(), device: "desk".into(), ..Request::default() }
2073 }
2074
2075 fn step(dir: &Path, requests: &[Request], here: &str, now: i64, ahead: i64) -> Vec<String> {
2078 let ledger = Ledger::open(dir, now).unwrap();
2079 let mut sent = Vec::new();
2080 for r in requests {
2081 if verdict(&Request { ahead, ..r.clone() }, here, now, &ledger, fr(), &|_| Standing::Counted) == Verdict::Send {
2082 record(dir, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, fr()), hash: words_hash(&r.body), ..Claim::default() }).unwrap();
2083 sent.push(r.key.clone());
2084 }
2085 }
2086 sent
2087 }
2088
2089 #[test]
2090 fn a_request_is_sent_once_whatever_is_read_again() {
2091 let dir = scratch("once");
2092 Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
2093 let r = request("a1", "phone", NOW - MINUTE);
2094 assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW, 0), ["a1"]);
2095 for later in [NOW + MINUTE, NOW + 2 * MINUTE] {
2096 assert!(step(&dir, &[r.clone(), r.clone()], "phone", later, 0).is_empty());
2097 }
2098 assert_eq!(Ledger::open(&dir, NOW).unwrap().state("a1"), Some("claimed"));
2099 let _ = std::fs::remove_dir_all(&dir);
2100 }
2101
2102 #[test]
2110 fn requests_name_their_writer_and_only_counted_devices_ask() {
2111 let dir = scratch("writers");
2112 let seal = Stand;
2113 let root = dir.join("data");
2114 let mine = Request { writer: String::new(), device: String::new(), ..request("w1", "phone", NOW) };
2115 write_request(&root, "desk", &mine, &seal).unwrap();
2116 let line = std::fs::read_to_string(own_file(&root, SEND, "desk")).unwrap();
2118 std::fs::create_dir_all(root.join(SEND)).unwrap();
2119 std::fs::write(own_file(&root, SEND, "laptop"), &line).unwrap();
2120 let read = read_requests(&root, &seal);
2121 assert_eq!(read.iter().map(|r| (r.key.as_str(), r.writer.as_str(), r.device.as_str())).collect::<Vec<_>>(), [("w1", "desk", "desk")], "the moved line passed over");
2122 let private = dir.join("private");
2124 std::fs::create_dir_all(&private).unwrap();
2125 std::fs::write(private.join(MARK), r#"{"since":1000}"#).unwrap();
2126 let ledger = Ledger::open(&private, NOW).unwrap();
2127 assert_eq!(ledger.mark, Mark { since: 1000, writers: NOW });
2128 assert_eq!(Ledger::open(&private, NOW + DAY_MS).unwrap().mark, Mark { since: 1000, writers: NOW }, "once");
2129 let counted = |device: &str| if device == "desk" { Standing::Counted } else { Standing::Gone };
2130 assert_eq!(verdict(&read[0], "phone", NOW, &ledger, fr(), &counted), Verdict::Send);
2131 let forgotten = Request { writer: "old-laptop".into(), device: "old-laptop".into(), ..request("w2", "phone", NOW) };
2132 assert_eq!(verdict(&forgotten, "phone", NOW, &ledger, fr(), &counted), Verdict::Refused("not-counted"));
2133 let older = Request { writer: String::new(), ..request("w3", "phone", NOW - MINUTE) };
2134 assert_eq!(verdict(&older, "phone", NOW, &ledger, fr(), &counted), Verdict::Send, "written before the phone checked: by its file's device");
2135 assert_eq!(verdict(&Request { device: "old-laptop".into(), ..older.clone() }, "phone", NOW, &ledger, fr(), &counted), Verdict::Refused("not-counted"));
2136 let after = Request { writer: String::new(), ..request("w4", "phone", NOW + MINUTE) };
2137 assert_eq!(verdict(&after, "phone", NOW + MINUTE, &ledger, fr(), &counted), Verdict::Refused("old-computer"));
2138 let unknown = |_: &str| Standing::Unknown;
2140 assert_eq!(verdict(&read[0], "phone", NOW, &ledger, fr(), &unknown), Verdict::Wait);
2141 assert_eq!(verdict(&read[0], "phone", NOW + 16 * MINUTE, &ledger, fr(), &unknown), Verdict::Expired);
2142 assert_eq!(verdict(&Request { written: NOW + 30 * MINUTE, ..read[0].clone() }, "phone", NOW, &ledger, fr(), &unknown), Verdict::Clocks);
2143 let decided = Ledger::with(Mark { since: 0, writers: NOW }, vec![Claim { key: "w4".into(), at: NOW, state: "done".into(), ..Claim::default() }]);
2144 assert_eq!(verdict(&after, "phone", NOW + MINUTE, &decided, fr(), &|_| panic!("asked only of a request not decided yet")), Verdict::Decided);
2145 let old: Request = serde_json::from_str(&serde_json::to_string(&read[0]).unwrap()).unwrap();
2147 assert_eq!((old.writer.as_str(), old.body.as_str()), ("desk", read[0].body.as_str()));
2148 let tr = Translator::new("en");
2149 for why in ["not-counted", "old-computer"] {
2150 let words = said(Some(&Outcome { state: "refused".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &paris(NOW), false);
2151 assert!(words.text != tr.text("texts-refused-other", None) && words.again == (why == "old-computer"), "{why}: {}", words.text);
2152 }
2153 let _ = std::fs::remove_dir_all(&dir);
2154 }
2155
2156 #[test]
2157 fn a_lost_ledger_s_mark_refuses_the_requests_written_before_it() {
2158 let dir = scratch("lost");
2159 Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
2160 let r = request("b1", "phone", NOW - 3 * MINUTE);
2161 assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW - 2 * MINUTE, 0), ["b1"]);
2162 std::fs::remove_dir_all(&dir).unwrap();
2163 let ledger = Ledger::open(&dir, NOW).unwrap();
2164 assert_eq!(verdict(&r, "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::BeforeMark, "within its 15 minutes, still never again");
2165 assert!(step(&dir, &[r], "phone", NOW, 0).is_empty());
2166 assert_eq!(step(&dir, &[request("b2", "phone", NOW + MINUTE)], "phone", NOW + MINUTE, 0), ["b2"]);
2167 let _ = std::fs::remove_dir_all(&dir);
2168 }
2169
2170 #[test]
2171 fn two_phones_each_send_only_their_own_and_two_computers_both_send() {
2172 let (a, b) = (scratch("phone-a"), scratch("phone-b"));
2173 Ledger::open(&a, NOW - 10 * MINUTE).unwrap();
2174 Ledger::open(&b, NOW - 10 * MINUTE).unwrap();
2175 let from_desk = request("c1", "phone-a", NOW - MINUTE);
2176 let from_laptop = Request { device: "laptop".into(), ..request("c2", "phone-a", NOW - MINUTE) };
2177 let to_b = request("c3", "phone-b", NOW - MINUTE);
2178 let all = [from_desk, from_laptop, to_b];
2179 assert_eq!(step(&a, &all, "phone-a", NOW, 0), ["c1", "c2"], "two computers, two keys, two texts");
2180 assert_eq!(step(&b, &all, "phone-b", NOW, 0), ["c3"]);
2181 let _ = (std::fs::remove_dir_all(&a), std::fs::remove_dir_all(&b));
2182 }
2183
2184 #[test]
2185 fn an_expired_request_and_clocks_apart() {
2186 let dir = scratch("expired");
2187 Ledger::open(&dir, NOW - DAY_MS).unwrap();
2188 let ledger = Ledger::open(&dir, NOW).unwrap();
2189 assert_eq!(verdict(&request("d1", "phone", NOW - 16 * MINUTE), "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Expired);
2190 assert_eq!(verdict(&request("d2", "phone", NOW - 14 * MINUTE), "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Send);
2191 let ahead = 60 * MINUTE;
2193 assert_eq!(verdict(&Request { ahead, ..request("d3", "phone", NOW + ahead - 20 * MINUTE) }, "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Expired);
2194 assert_eq!(verdict(&Request { ahead, ..request("d4", "phone", NOW + ahead - 5 * MINUTE) }, "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Send);
2195 assert_eq!(verdict(&request("d5", "phone", NOW + 30 * MINUTE), "phone", NOW, &ledger, fr(), &|_| Standing::Counted), Verdict::Clocks);
2197 let _ = std::fs::remove_dir_all(&dir);
2198 }
2199
2200 #[test]
2201 fn short_numbers_groups_and_empty_words_are_refused() {
2202 let ledger = Ledger::with(Mark { since: 0, writers: 0 }, Vec::new());
2203 let with = |to: &str, body: &str| verdict(&Request { to: to.into(), body: body.into(), ..request("e", "phone", NOW) }, "phone", NOW, &ledger, fr(), &|_| Standing::Counted);
2204 assert_eq!(with("36179", "STOP"), Verdict::Refused("short-number"));
2205 assert_eq!(with("01 99 00 12 34, 04 65 71 00 42", "Bonjour"), Verdict::Refused("several"));
2206 assert_eq!(with("01 99 00 12 34", " "), Verdict::Refused("empty"));
2207 assert_eq!(with("01 99 00 12 34", &"x".repeat(WRITE_MAX + 1)), Verdict::Refused("too-long"));
2208 assert_eq!(with("+33 1 99 00 12 34", "Bonjour"), Verdict::Send);
2209 }
2210
2211 #[test]
2212 fn a_crash_between_the_claim_and_the_result_is_said_never_sent_again() {
2213 let claim = Claim { key: "f1".into(), at: NOW, state: "claimed".into(), to: "+33199001234".into(), hash: words_hash("Bonjour"), ..Claim::default() };
2214 assert!(doubt_after(&claim, None, NOW + MINUTE).is_none(), "Android's word may still come");
2215 let found = doubt_after(&claim, Some("sms-412".into()), NOW + 3 * MINUTE).unwrap();
2216 assert_eq!((found.state.as_str(), found.row.as_str()), ("sent", "sms-412"));
2217 assert_eq!(doubt_after(&claim, None, NOW + 3 * MINUTE).unwrap().state, "doubt");
2218 let ledger = Ledger::with(Mark { since: 0, writers: 0 }, vec![claim]);
2219 assert_eq!(verdict(&request("f1", "phone", NOW + 3 * MINUTE), "phone", NOW + 3 * MINUTE, &ledger, fr(), &|_| Standing::Counted), Verdict::Decided);
2220 }
2221
2222 #[test]
2223 fn the_ledger_is_flushed_before_android_is_asked_and_trimmed_after_a_month() {
2224 let dir = scratch("ledger");
2225 record(&dir, &Claim { key: "g1".into(), at: NOW - 31 * DAY_MS, state: "done".into(), ..Claim::default() }).unwrap();
2226 record(&dir, &Claim { key: "g2".into(), at: NOW, state: "claimed".into(), ..Claim::default() }).unwrap();
2227 assert!(std::fs::read_to_string(dir.join(LEDGER)).unwrap().contains("\"g2\""));
2228 assert_eq!(trim_ledger(&dir, NOW).unwrap(), 1);
2229 let ledger = Ledger::open(&dir, NOW).unwrap();
2230 assert!(!ledger.has("g1") && ledger.has("g2"));
2231 let _ = std::fs::remove_dir_all(&dir);
2232 }
2233
2234 fn message(id: &str, at: i64, body: &str) -> Text {
2235 Text { id: id.into(), at, thread: "7".into(), direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), sub: 1, ..Text::default() }
2236 }
2237
2238 #[test]
2239 fn the_history_is_sealed_padded_kept_whole_and_a_deletion_marks_it() {
2240 let dir = scratch("lines");
2241 let own = own_file(&dir, LOG, "phone");
2242 let id = |t: &Text| t.line_id();
2243 let old = NOW - 3 * 365 * DAY_MS;
2244 assert_eq!(append(&own, &[message("sms-1", old, "Bonjour"), message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 2);
2245 assert_eq!(append(&own, &[message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 0, "read again: once");
2246 let raw = std::fs::read_to_string(&own).unwrap();
2247 assert!(!raw.contains("Bonjour") && !raw.contains("199001234"), "sealed at rest");
2248 for line in raw.lines() {
2249 assert_eq!(Stand.open(line).unwrap().len() % PAD_STEP, 0);
2250 }
2251 let later = NOW + 400 * DAY_MS;
2253 assert_eq!(trim_own(&dir, "phone", &Stand, later).unwrap() + trim_others(&dir, "desk", &Stand, later).unwrap(), 0);
2254 assert_eq!(read_logs(&dir, &Stand).len(), 2);
2255 let deletion = Text { deleted: NOW + MINUTE, ..message("sms-1", old, "") };
2257 assert_eq!(append(&own, &[deletion], &Stand, Some(&id)).unwrap(), 1);
2258 let back = read_logs(&dir, &Stand);
2259 assert_eq!(back.iter().map(|t| (t.id.as_str(), t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("sms-1", "Bonjour", true), ("sms-2", "Votre rendez-vous est déplacé.", false)]);
2260 let _ = std::fs::remove_dir_all(&dir);
2261 }
2262
2263 #[test]
2264 fn parts_are_counted_as_android_counts_them() {
2265 assert_eq!(parts(""), (0, true));
2266 assert_eq!(parts("Bonjour, à demain !"), (1, true));
2267 assert_eq!(parts(&"a".repeat(160)), (1, true));
2268 assert_eq!(parts(&"a".repeat(161)), (2, true));
2269 assert_eq!(parts(&"€".repeat(80)), (1, true), "two septets each: 160");
2270 assert_eq!(parts(&"€".repeat(81)), (2, true));
2271 assert_eq!(parts("Ça va ? 🙂"), (1, false), "an emoji: UCS-2");
2272 assert_eq!(parts(&"ç".repeat(71)), (2, false), "ç is no GSM letter (Ç is)");
2273 }
2274
2275 #[test]
2277 fn a_thread_says_each_message_s_day_and_time() {
2278 let tr = Translator::new("en");
2279 let now = paris(NOW);
2280 let names = |_: &str| None;
2281 let here = |_: &str| false;
2282 let v = viewer(&now, &tr, &names, &here);
2283 let row = |id: &str, at: i64| Text { id: id.into(), at, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), device: "phone".into(), ..Text::default() };
2284 let texts = [row("sms-1", NOW - 400 * 86_400_000), row("sms-2", NOW - 86_400_000), row("sms-3", NOW - MINUTE)];
2285 let said: Vec<(String, String)> = messages("+33199001234", &texts, &[], &[], &v).into_iter().map(|m| (m.day, m.time)).collect();
2286 assert_eq!(said[1], ("Yesterday".to_string(), "10:00".to_string()));
2287 assert_eq!(said[2], ("Today".to_string(), "09:59".to_string()));
2288 assert!(said[0].0.ends_with(" 2025") && said[0].0.contains("September"), "another year said: {:?}", said[0]);
2289 }
2290
2291 #[test]
2292 fn the_archive_is_searched_by_words_names_and_numbers_newest_first() {
2293 let tr = Translator::new("en");
2294 let now = paris(NOW);
2295 let names = |k: &str| (k == "+33199001234").then(|| "Pharmacie Sainte-Hélène".to_string());
2296 let here = |_: &str| false;
2297 let v = viewer(&now, &tr, &names, &here);
2298 let row = |id: &str, at: i64, with: &str, body: &str| Text { id: id.into(), at, direction: "in".into(), with: vec![with.into()], body: body.into(), device: "phone".into(), ..Text::default() };
2299 let texts = [
2300 row("sms-1", NOW - 3 * MINUTE, "+33199001234", "Votre ordonnance est prête."),
2301 row("sms-2", NOW - 2 * MINUTE, "+33465710042", "Le colis est arrivé au relais."),
2302 row("sms-3", NOW - MINUTE, "+33199001234", "Ordonnance renouvelée, à retirer demain."),
2303 ];
2304 let ids = |query: &str, limit: usize| search(query, &texts, &[], &[], &v, limit).into_iter().map(|f| f.message.id).collect::<Vec<_>>();
2305 assert_eq!(ids("ORDONNANCE", 10), ["phone/sms-3", "phone/sms-1"], "case aside, newest first");
2306 assert_eq!(ids("arrive", 10), ["phone/sms-2"], "accents aside");
2307 assert_eq!(ids("helene pret", 10), ["phone/sms-1"], "a name and a word, both");
2308 assert_eq!(ids("04 65 71", 10), ["phone/sms-2"], "a number as written");
2309 assert_eq!(ids("ordonnance", 1), ["phone/sms-3"]);
2310 assert!(ids(" ", 10).is_empty());
2311 let found = &search("colis", &texts, &[], &[], &v, 10)[0];
2312 assert_eq!((found.conversation.as_str(), found.title.as_str()), ("+33465710042", "04 65 71 00 42"));
2313 }
2314
2315 fn viewer<'a>(now: &'a Zoned, tr: &'a Translator, names: &'a dyn Fn(&str) -> Option<String>, here: &'a dyn Fn(&str) -> bool) -> Viewer<'a> {
2316 Viewer { now, tr, region: fr(), name_of: names, phone_shared: Some(NOW - 2 * MINUTE), media_here: here }
2317 }
2318
2319 fn paris(ms: i64) -> Zoned {
2320 jiff::Timestamp::from_millisecond(ms).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap())
2321 }
2322
2323 #[test]
2324 fn the_page_shows_conversations_and_each_request_s_state_in_words() {
2325 let tr = Translator::new("en");
2326 let now = paris(NOW);
2327 let names = |k: &str| (k == "+33199001234").then(|| "Dr Martin".to_string());
2328 let here = |_: &str| true;
2329 let v = viewer(&now, &tr, &names, &here);
2330 let texts = vec![
2331 Text { id: "sms-1".into(), at: NOW - 60 * MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: "Votre rendez-vous est déplacé.".into(), sub: 2, device: "phone".into(), ..Text::default() },
2332 Text { id: "sms-2".into(), at: NOW - 30 * MINUTE, direction: "in".into(), with: vec!["+33465710042".into()], body: "Le colis est arrivé.".into(), sub: 1, device: "phone".into(), ..Text::default() },
2333 Text { id: "sms-9".into(), at: NOW - 5 * MINUTE, direction: "out".into(), with: vec!["+33199001234".into()], body: "Merci, c'est noté.".into(), sub: 2, device: "phone".into(), ..Text::default() },
2334 ];
2335 let requests = vec![Request { body: "Merci, c'est noté.".into(), ..request("k1", "phone", NOW - 6 * MINUTE) }, request("k2", "phone", NOW - MINUTE), Request { to: "04 65 71 00 42".into(), ..request("k3", "phone", NOW - 20 * MINUTE) }];
2336 let outcomes = vec![
2337 Outcome { key: "k1".into(), state: "handed".into(), at: NOW - 5 * MINUTE, ..Outcome::default() },
2338 Outcome { key: "k1".into(), state: "sent".into(), at: NOW - 5 * MINUTE, row: "sms-9".into(), ..Outcome::default() },
2339 Outcome { key: "k3".into(), state: "expired".into(), at: NOW - 2 * MINUTE, ..Outcome::default() },
2340 ];
2341 let list = conversations(&texts, &requests, &v);
2342 assert_eq!(list.iter().map(|c| c.title.as_str()).collect::<Vec<_>>(), ["Dr Martin", "04 65 71 00 42"]);
2343 let shown = messages("+33199001234", &texts, &requests, &outcomes, &v);
2344 assert_eq!(shown.iter().map(|m| (m.direction.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("in", ""), ("out", "k1"), ("out", "k2")]);
2345 assert_eq!(shown[1].said.as_ref().unwrap().text, "Sent at 09:55.");
2346 assert_eq!(shown[2].said.as_ref().unwrap().text, "Waiting for your phone (it last shared at 09:58).");
2347 let other = messages("+33465710042", &texts, &requests, &outcomes, &v);
2348 let expired = other.iter().find(|m| m.key == "k3").unwrap().said.clone().unwrap();
2349 assert!(expired.again && expired.state == "expired", "{expired:?}");
2350 assert_eq!(last_sim("+33199001234", "phone", &texts), 2);
2351 }
2352
2353 #[test]
2354 fn a_message_s_media_are_said_with_their_sizes_or_why_they_are_not_here() {
2355 let tr = Translator::new("en");
2356 let now = paris(NOW);
2357 let names = |_: &str| None;
2358 let here = |hash: &str| hash == "aa";
2359 let v = viewer(&now, &tr, &names, &here);
2360 let part = |seq: i64, ct: &str, size: u64, hash: &str, state: &str| Part { seq, ct: ct.into(), size, hash: hash.into(), state: state.into(), ..Part::default() };
2361 let mms = Text {
2362 id: "mms-31".into(),
2363 at: NOW - MINUTE,
2364 direction: "in".into(),
2365 with: vec!["+33465710042".into(), "+33199001234".into()],
2366 from: "+33465710042".into(),
2367 body: "Le colis.".into(),
2368 parts: vec![part(-1, "application/smil", 300, "", "text"), part(0, "image/jpeg", 220_000, "aa", "here"), part(1, "video/mp4", 30_000_000, "", "too-big"), part(2, "audio/amr", 9_000, "bb", "here"), part(3, "image/png", 0, "", "not-downloaded"), part(4, "text/plain", 9, "", "text")],
2369 device: "phone".into(),
2370 ..Text::default()
2371 };
2372 let shown = messages(&conversation_id(&mms.with), std::slice::from_ref(&mms), &[], &[], &v);
2373 let said: Vec<(&str, &str, bool)> = shown[0].parts.iter().map(|p| (p.kind.as_str(), p.said.as_str(), !p.hash.is_empty())).collect();
2374 assert_eq!(said.len(), 4, "the SMIL and the words are not shown apart: {said:?}");
2375 assert_eq!(said[0].0, "picture");
2376 assert!(said[0].2, "a picture here opens");
2377 assert!(said[1].1.contains("on your phone") && !said[1].2, "{said:?}");
2378 assert!(said[2].1.contains("its way") && !said[2].2, "its file not come yet: {said:?}");
2379 assert!(said[3].1.contains("not downloaded"), "{said:?}");
2380 assert_eq!(shown[0].from, "04 65 71 00 42", "a group says who wrote");
2381 assert_eq!(kept(std::slice::from_ref(&mms), &here), (1, 220_000), "only what is here is counted");
2382 }
2383
2384 #[test]
2385 fn delivered_is_said_only_when_the_carrier_says_so() {
2386 let tr = Translator::new("en");
2387 let now = paris(NOW);
2388 let sent = [Outcome { key: "h1".into(), state: "sent".into(), at: NOW + MINUTE, ..Outcome::default() }];
2389 assert_eq!(said(state_of("h1", &sent), None, &tr, &now, false).state, "sent", "no report: sent, never 'not delivered'");
2390 let delivered = [sent[0].clone(), Outcome { key: "h1".into(), state: "delivered".into(), at: NOW + 2 * MINUTE, ..Outcome::default() }];
2391 assert_eq!(said(state_of("h1", &delivered), None, &tr, &now, false).text, "Delivered at 10:02.");
2392 }
2393
2394 fn row(phone: &str, id: &str, at: i64, direction: &str, address: &str, body: &str) -> Text {
2396 let fields = [("address", serde_json::json!(address)), ("body", serde_json::json!(body)), ("date", serde_json::json!(at))].into_iter().map(|(k, v)| (k.to_string(), v)).collect();
2397 Text { id: id.into(), at, thread: "7".into(), direction: direction.into(), with: vec![phones::key(address, fr())], body: body.into(), sub: 1, fields, device: phone.into(), ..Text::default() }
2398 }
2399
2400 fn addr(address: &str, kind: i64) -> BTreeMap<String, Value> {
2402 [("address", serde_json::json!(address)), ("type", serde_json::json!(kind)), ("charset", serde_json::json!(106))].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
2403 }
2404
2405 fn picture_message(phone: &str, id: &str, at: i64, own: &str, state: &str) -> Text {
2408 let part = |seq: i64, ct: &str, text: &str, hash: &str, state: &str| Part { seq, ct: ct.into(), text: text.into(), hash: hash.into(), state: state.into(), size: if text.is_empty() { 220_000 } else { 0 }, ..Part::default() };
2409 let picture = if state == "here" { "aa".repeat(32) } else { String::new() };
2410 Text {
2411 id: id.into(),
2412 at,
2413 thread: "9".into(),
2414 direction: "in".into(),
2415 with: vec!["+33465710042".into()],
2416 body: "Le colis.".into(),
2417 picture: true,
2418 parts: vec![part(-1, "application/smil", "<smil/>", "", "text"), part(0, "image/jpeg", "", &picture, state), part(1, "text/plain", "Le colis.", "", "text")],
2419 addrs: vec![addr("+33465710042", ADDR_FROM), addr(own, ADDR_TO)],
2420 device: phone.into(),
2421 ..Text::default()
2422 }
2423 }
2424
2425 fn logs(mut all: Vec<Text>) -> Vec<Text> {
2427 all.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
2428 all
2429 }
2430
2431 fn two_phones(old: &str, new: &str) -> (Vec<Text>, Vec<Text>) {
2437 let second = |s: i64| NOW - 2 * DAY_MS + s * 1000;
2438 let at = NOW - 3 * DAY_MS + 123;
2439 let old_log = vec![
2440 row(old, "sms-10", at, "in", "01 99 00 12 34", "Le rendez-vous est à 9 h."),
2441 row(old, "sms-11", second(0) + 100, "out", "0199001234", "OK"),
2443 row(old, "sms-12", second(0) + 400, "out", "0199001234", "OK"),
2444 picture_message(old, "mms-3", second(60), "+33536490000", "here"),
2445 Text { deleted: NOW - DAY_MS, ..row(old, "sms-13", second(120), "in", "+33465710042", "Votre colis arrive demain.") },
2446 ];
2447 let new_log = vec![
2448 Text { thread: "2".into(), ..row(new, "sms-501", at, "in", "01 99 00 12 34", "Le rendez-vous est à 9 h.") },
2449 row(new, "sms-502", second(0) + 100, "out", "0199001234", "OK"),
2450 row(new, "sms-503", second(0) + 400, "out", "0199001234", "OK"),
2451 picture_message(new, "mms-77", second(60), OWN_ADDRESS, "too-big"),
2452 row(new, "sms-504", second(120), "in", "+33465710042", "Votre colis arrive demain."),
2453 row(new, "sms-600", NOW - MINUTE, "in", "0199001234", "À tout à l'heure."),
2454 row(new, "sms-11", second(30), "out", "0199001234", "OK"),
2455 ];
2456 (old_log, new_log)
2457 }
2458
2459 #[test]
2464 fn the_matching_rule_is_what_a_restore_keeps() {
2465 let key = |t: &Text| Print::of(t).key(fr());
2466 let text = row("a", "sms-1", NOW + 250, "in", "01 99 00 12 34", "Bonjour");
2467 assert_eq!(key(&text), key(&Text { id: "sms-900".into(), thread: "41".into(), device: "b".into(), ..row("b", "sms-900", NOW + 900, "in", "+33 1 99 00 12 34", "Bonjour") }), "another row and number form, the same second");
2468 for other in [row("a", "sms-1", NOW + 1000, "in", "01 99 00 12 34", "Bonjour"), row("a", "sms-1", NOW, "out", "01 99 00 12 34", "Bonjour"), row("a", "sms-1", NOW, "in", "01 99 00 12 35", "Bonjour"), row("a", "sms-1", NOW, "in", "01 99 00 12 34", "Bonjour !")] {
2469 assert_ne!(key(&text), key(&other), "{other:?}");
2470 }
2471 assert_eq!(key(&row("a", "sms-1", NOW, "in", "0199001234", "Page\u{c}break")), key(&row("b", "sms-2", NOW, "in", "0199001234", "Pagebreak")), "a form feed no backup can hold");
2472 let picture = picture_message("a", "mms-3", NOW, "+33536490000", "here");
2474 let restored = picture_message("b", "mms-77", NOW, OWN_ADDRESS, "too-big");
2475 assert_eq!(key(&picture), key(&restored), "here on one phone, too large on the other: the same");
2476 let upper = Text { parts: picture.parts.iter().map(|p| if p.ct == "image/jpeg" { Part { ct: "IMAGE/JPEG".into(), ..p.clone() } } else { p.clone() }).collect(), ..picture.clone() };
2477 assert_eq!(key(&upper), key(&picture), "a type's case aside");
2478 let other_words = Text { parts: picture.parts.iter().map(|p| if p.ct == "text/plain" { Part { text: "Le colis est là.".into(), ..p.clone() } } else { p.clone() }).collect(), ..picture.clone() };
2479 assert_ne!(key(&other_words), key(&picture));
2480 assert_eq!(Print::of(&picture).people, ["+33465710042"], "received: its sender");
2481 let sent = |own: &str| Text { direction: "out".into(), addrs: vec![addr(own, ADDR_FROM), addr("04 65 71 00 42", ADDR_TO), addr("+33199001234", ADDR_CC)], ..picture.clone() };
2483 assert_eq!(Print::of(&sent(OWN_ADDRESS)).people, ["04 65 71 00 42", "+33199001234"]);
2484 assert_eq!(key(&sent(OWN_ADDRESS)), key(&sent("+33536490000")));
2485 assert_ne!(key(&picture), key(&sent(OWN_ADDRESS)), "sent, it is another");
2486 let backup_null = Text { parts: [picture.parts.clone(), vec![Part { seq: 2, ct: "text/plain".into(), text: "null".into(), state: "text".into(), ..Part::default() }]].concat(), ..picture.clone() };
2487 assert_eq!(key(&backup_null), key(&picture), "a text part SMS Backup & Restore wrote \"null\": none");
2488 }
2489
2490 #[test]
2495 fn a_text_in_two_phones_logs_shows_once_and_one_sent_twice_stays_twice() {
2496 for (old, new) in [("old-phone", "new-phone"), ("zz-old", "new-phone")] {
2497 let (old_log, new_log) = two_phones(old, new);
2498 assert_eq!(one_each(logs(old_log.clone()), fr()), logs(old_log.clone()), "one phone: as it is");
2499 let shown = one_each(logs([old_log.clone(), new_log.clone()].concat()), fr());
2500 assert_eq!(shown.len(), 7, "{old}: {:?}", shown.iter().map(|t| format!("{}/{}", t.device, t.id)).collect::<Vec<_>>());
2501 let twice: Vec<&Text> = shown.iter().filter(|t| t.body == "OK" && t.at < NOW - 2 * DAY_MS + 1000).collect();
2502 assert_eq!(twice.len(), 2, "sent twice, twice");
2503 assert!(twice.iter().all(|t| t.also.len() == 1));
2504 let mms = shown.iter().find(|t| t.id.starts_with("mms-")).unwrap();
2505 assert_eq!((mms.device.as_str(), mms.parts[1].state.as_str()), (old, "here"), "the copy with its picture");
2506 assert_eq!(mms.also, [format!("{new}/mms-77")]);
2507 let deleted = shown.iter().find(|t| t.body.starts_with("Votre colis")).unwrap();
2508 assert_eq!(deleted.deleted, 0, "still on the new phone");
2509 assert_eq!(shown.iter().filter(|t| t.body == "OK").count(), 3, "the new phone's own OK, another second");
2510 let both_gone = one_each(logs(vec![Text { deleted: NOW - DAY_MS, ..row(old, "sms-1", NOW, "in", "0199001234", "x") }, Text { deleted: NOW - MINUTE, ..row(new, "sms-9", NOW, "in", "0199001234", "x") }]), fr());
2512 assert_eq!(both_gone.iter().map(|t| t.deleted).collect::<Vec<_>>(), [NOW - MINUTE]);
2513 let once_there = one_each(logs([old_log.clone(), vec![row(new, "sms-502", NOW - 2 * DAY_MS + 100, "out", "0199001234", "OK")]].concat()), fr());
2515 assert_eq!(once_there.iter().filter(|t| t.body == "OK").count(), 2);
2516 }
2517 let (old_log, new_log) = two_phones("old-phone", "new-phone");
2519 let shown = one_each(logs([old_log, new_log].concat()), fr());
2520 let tr = Translator::new("en");
2521 let now = paris(NOW);
2522 let names = |_: &str| None;
2523 let here = |_: &str| true;
2524 let v = viewer(&now, &tr, &names, &here);
2525 let requests = [Request { to: "0199001234".into(), body: "OK".into(), ..request("k1", "old-phone", NOW - 2 * DAY_MS - 30_000) }];
2526 let outcomes = [Outcome { key: "k1".into(), state: "sent".into(), at: NOW - 2 * DAY_MS + 100, row: "sms-11".into(), device: "old-phone".into(), ..Outcome::default() }];
2527 assert_eq!(conversations(&shown, &requests, &v).len(), 2);
2528 let thread = messages("+33199001234", &shown, &requests, &outcomes, &v);
2529 assert_eq!(thread.len(), 5, "{:?}", thread.iter().map(|m| (&m.id, &m.body, &m.key)).collect::<Vec<_>>());
2530 let sent: Vec<&Message> = thread.iter().filter(|m| m.key == "k1").collect();
2531 assert_eq!(sent.len(), 1, "the request said once, on its row");
2532 assert!(sent[0].id.starts_with("new-phone/sms-50") && sent[0].said.as_ref().is_some_and(|s| s.state == "sent"), "{:?}", sent[0]);
2533 assert!(thread.iter().any(|m| m.id == "new-phone/sms-11" && m.key.is_empty()), "another phone's row of that number: another text");
2534 assert_eq!(search("rendez", &shown, &requests, &outcomes, &v, 10).len(), 1);
2535 }
2536
2537 #[test]
2542 fn a_row_is_its_id_with_its_date() {
2543 let first = Row { mms: false, id: 12, date: 1_791_446_340_000 };
2544 assert_eq!(first.name(), "sms-12@1791446340000");
2545 assert_eq!(Row::of("sms-12@1791446340000", 0), Some(first));
2546 assert_eq!(Row::of("sms-12", 1_791_446_340_000), Some(first), "a line named before: its date from its time");
2547 let picture = Row { mms: true, id: 31, date: 1_791_446_340 };
2548 assert_eq!((Row::of("mms-31", 1_791_446_340_000), Row::of(&picture.name(), 0), picture.at()), (Some(picture), Some(picture), 1_791_446_340_000));
2549 assert_ne!(Row::of("sms-12@1791446400000", 0), Some(first), "the same id, another text");
2550 assert_eq!((Row::of("request/abc", 0), Row::of("sms-x", 0), Row::of("sms-12@x", 0)), (None, None, None));
2551 let second = Row { mms: false, id: 12, date: 1_791_446_400_000 };
2553 let line = |row: Row, deleted: i64| Text { id: row.name(), at: row.at(), device: "p".into(), deleted, with: vec!["+33199001234".into()], ..Text::default() };
2554 let folded = fold_logs(vec![line(first, 0), line(second, 0), line(first, 1_791_446_500_000)]);
2555 assert_eq!(folded.iter().map(|t| (t.id.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("sms-12@1791446340000", true), ("sms-12@1791446400000", false)]);
2556 let tr = Translator::new("en");
2558 let now = paris(NOW);
2559 let names = |_: &str| None;
2560 let here = |_: &str| true;
2561 let v = viewer(&now, &tr, &names, &here);
2562 let requests = [request("k9", "p", 1_791_446_390_000)];
2563 let outcomes = [Outcome { key: "k9".into(), state: "sent".into(), at: 1_791_446_400_000, row: second.name(), device: "p".into(), ..Outcome::default() }];
2564 let thread = messages("+33199001234", &folded, &requests, &outcomes, &v);
2565 assert_eq!(thread.iter().map(|m| (m.id.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("p/sms-12@1791446340000", ""), ("p/sms-12@1791446400000", "k9")]);
2566 let late = Row { mms: false, id: 12, date: 1_791_446_530_000 };
2569 let requests = [request("k8", "p", 1_791_446_520_000)];
2570 let bare = [Outcome { key: "k8".into(), state: "sent".into(), at: 1_791_446_535_000, row: "sms-12".into(), device: "p".into(), ..Outcome::default() }];
2571 let older = Text { id: "sms-12".into(), ..line(first, 0) };
2572 let thread = messages("+33199001234", &fold_logs(vec![older.clone(), line(late, 0)]), &requests, &bare, &v);
2573 assert_eq!(thread.iter().map(|m| (m.id.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("p/sms-12", ""), ("p/sms-12@1791446530000", "k8")]);
2574 let thread = messages("+33199001234", &fold_logs(vec![older]), &requests, &bare, &v);
2575 assert!(thread.iter().find(|m| m.id == "p/sms-12").is_some_and(|m| m.key.is_empty()), "the text sent not read yet: not the older one's");
2576 }
2577
2578 #[test]
2583 fn the_logs_are_read_again_only_where_they_grew() {
2584 use std::sync::atomic::{AtomicUsize, Ordering};
2585 struct Counted(AtomicUsize);
2587 impl Sealer for Counted {
2588 fn seal(&self, plain: &str) -> String {
2589 Stand.seal(plain)
2590 }
2591 fn open(&self, sealed: &str) -> Option<String> {
2592 self.0.fetch_add(1, Ordering::Relaxed);
2593 Stand.open(sealed)
2594 }
2595 }
2596 let dir = scratch("logs");
2597 let seal = Counted(AtomicUsize::new(0));
2598 let opened = || seal.0.swap(0, Ordering::Relaxed);
2599 let text = |id: i64, body: &str| Text { id: Row { mms: false, id, date: NOW + id }.name(), at: NOW + id, direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), ..Text::default() };
2600 let (a, b) = (own_file(&dir, LOG, "a"), own_file(&dir, LOG, "b"));
2601 append(&a, &[text(1, "Un."), text(2, "Deux.")], &seal, None).unwrap();
2602 let mut raw = text(8, "Deux.");
2604 raw.fields.insert("address".into(), Value::from("04 65 71 00 42"));
2605 append(&b, &[text(7, "Un."), raw], &seal, None).unwrap();
2606 opened();
2607 let mut logs = Logs::default();
2608 let bare = |texts: Vec<Text>| texts.into_iter().map(|t| Text { fields: BTreeMap::new(), addrs: Vec::new(), ..t }).collect::<Vec<_>>();
2609 let same = |logs: &mut Logs| {
2610 let n = seal.0.load(Ordering::Relaxed);
2611 assert_eq!(logs.texts(), bare(read_logs(&dir, &seal)));
2612 assert_eq!(logs.one_each(fr()), bare(one_each(read_logs(&dir, &seal), fr())));
2613 seal.0.store(n, Ordering::Relaxed);
2614 };
2615 assert!(logs.refresh(&dir, &seal));
2616 assert_eq!(opened(), 4);
2617 assert!(logs.texts().iter().all(|t| t.fields.is_empty() && t.addrs.is_empty()), "no raw columns kept");
2618 same(&mut logs);
2620 assert!(!logs.refresh(&dir, &seal), "nothing changed");
2621 assert_eq!(opened(), 0);
2622 append(&a, &[text(3, "Trois."), Text { deleted: NOW + 9, ..text(1, "") }], &seal, None).unwrap();
2624 let line = format!("{}\n", seal.seal(&serde_json::to_string(&text(4, "Quatre.")).unwrap()));
2625 let (half, rest) = line.split_at(line.len() / 2);
2626 calls::append_private(&a, half).unwrap();
2627 assert!(logs.refresh(&dir, &seal));
2628 assert_eq!(opened(), 2, "the lines added, alone");
2629 same(&mut logs);
2630 calls::append_private(&a, rest).unwrap();
2631 assert!(logs.refresh(&dir, &seal));
2632 assert_eq!(opened(), 1);
2633 same(&mut logs);
2634 assert_eq!(logs.texts().iter().filter(|t| t.device == "a").count(), 4);
2635 std::fs::remove_file(&a).unwrap();
2637 append(&a, &(10..16).map(|id| text(id, "Autre.")).collect::<Vec<_>>(), &seal, None).unwrap();
2638 assert!(logs.refresh(&dir, &seal));
2639 assert_eq!(opened(), 6);
2640 same(&mut logs);
2641 std::fs::remove_file(&a).unwrap();
2642 append(&a, &[text(20, "Court.")], &seal, None).unwrap();
2643 assert!(logs.refresh(&dir, &seal));
2644 same(&mut logs);
2645 #[cfg(unix)]
2648 {
2649 let modified = std::fs::metadata(&a).unwrap().modified().unwrap();
2650 let body = std::fs::read(&a).unwrap();
2651 std::fs::remove_file(&a).unwrap();
2652 append(&a, &[text(20, "Kourt.")], &seal, None).unwrap();
2653 assert_eq!(std::fs::read(&a).unwrap().len(), body.len(), "the same size");
2654 std::fs::File::options().write(true).open(&a).unwrap().set_modified(modified).unwrap();
2655 assert!(logs.refresh(&dir, &seal), "another file, though its size and time are the old one's");
2656 same(&mut logs);
2657 assert!(logs.texts().iter().any(|t| t.body == "Kourt."));
2658 }
2659 std::fs::remove_file(&b).unwrap();
2661 assert!(logs.refresh(&dir, &seal));
2662 same(&mut logs);
2663 assert!(logs.texts().iter().all(|t| t.device == "a"));
2664 let _ = std::fs::remove_dir_all(&dir);
2665 }
2666
2667 #[test]
2676 fn each_row_is_one_text_on_a_computer() {
2677 const HOUR: i64 = 60 * MINUTE;
2678 let tr = Translator::new("en");
2679 let now = paris(NOW);
2680 let names = |_: &str| None;
2681 let here = |_: &str| true;
2682 let v = viewer(&now, &tr, &names, &here);
2683 let line = |id: &str, at: i64, body: &str| Text { id: id.into(), at, direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), device: "phone".into(), ..Text::default() };
2684 let t5 = NOW - 9 * HOUR;
2686 let t7 = NOW - 8 * HOUR;
2687 let lines = vec![line("sms-5", t5, "Bonjour."), line(&format!("sms-5@{t5}"), t5, "Bonjour."), line(&format!("sms-7@{t7}"), t7, "Au revoir."), Text { deleted: NOW - HOUR, ..line("sms-7", t7, "") }];
2688 let folded = fold_logs(lines);
2689 assert_eq!(folded.iter().map(|t| (t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("Bonjour.", false), ("Au revoir.", true)]);
2690 let t9 = NOW - 2 * HOUR;
2692 let first = Text { direction: "out".into(), ..line(&format!("sms-9@{t9}"), t9, "J'arrive.") };
2693 let then = Text { id: format!("sms-9@{}", t9 + 1), at: t9 + 1, ..first.clone() };
2694 let mark = Text { id: first.id.clone(), at: first.at, deleted: NOW - HOUR, moved: then.id.clone(), direction: "out".into(), with: first.with.clone(), device: "phone".into(), ..Text::default() };
2695 let folded = fold_logs(vec![first.clone(), then.clone(), mark]);
2696 assert_eq!(folded.len(), 1);
2697 assert_eq!((folded[0].id.as_str(), folded[0].also.clone()), (then.id.as_str(), vec![format!("phone/{}", first.id)]));
2698 let request = Request { key: "r1".into(), phone: "phone".into(), to: "+33199001234".into(), body: "J'arrive.".into(), written: t9 - 1_000, ..Request::default() };
2699 let sent = Outcome { key: "r1".into(), state: "sent".into(), at: t9 + 5_000, row: first.id.clone(), device: "phone".into(), ..Outcome::default() };
2700 let shown = messages(&conversation_id(&first.with), &folded, std::slice::from_ref(&request), std::slice::from_ref(&sent), &v);
2701 assert_eq!(shown.iter().map(|m| (m.id.clone(), m.key.clone())).collect::<Vec<_>>(), [(format!("phone/{}", then.id), "r1".to_string())], "one text, the request found by its old name");
2702 let oui = line(&format!("sms-11@{}", NOW - 6 * HOUR), NOW - 6 * HOUR, "Oui.");
2704 let later = line(&format!("sms-11@{}", NOW - 3 * HOUR), NOW - 3 * HOUR, "Oui.");
2705 assert!(!moved(&oui, &later), "a reused id, hours later: another text");
2706 assert!(moved(&oui, &line(&format!("sms-11@{}", NOW - 6 * HOUR + 1), NOW - 6 * HOUR + 1, "Oui.")), "re-dated by a millisecond");
2707 let out = |t: &Text| Text { direction: "out".into(), ..t.clone() };
2708 assert!(moved(&out(&oui), &out(&later)), "sent from the phone: its provider re-dates its own sending");
2709 let picture = |hash: &str| Text { id: format!("mms-3@{}", (NOW - 7 * HOUR) / 1000), at: NOW - 7 * HOUR, direction: "in".into(), with: vec!["+33199001234".into()], parts: vec![Part { ct: "image/jpeg".into(), hash: hash.into(), state: "here".into(), ..Part::default() }], device: "phone".into(), ..Text::default() };
2710 let again = |hash: &str| Text { id: format!("mms-3@{}", (NOW - 7 * HOUR) / 1000 + 1), at: NOW - 7 * HOUR + 1_000, ..picture(hash) };
2711 assert!(moved(&picture("aa11"), &again("aa11")), "the same picture re-dated");
2712 assert!(!moved(&picture("aa11"), &again("bb22")), "another picture under a reused id");
2713 assert!(moved(&picture(""), &again("bb22")), "read before its parts were attached: comes whole");
2714 let bare = Outcome { key: "r2".into(), state: "sent".into(), at: NOW - 5 * HOUR, row: "sms-12".into(), device: "phone".into(), ..Outcome::default() };
2716 let asked = Request { key: "r2".into(), phone: "phone".into(), to: "+33199001234".into(), body: "Merci.".into(), written: NOW - 5 * HOUR - 30_000, ..Request::default() };
2717 let filed = Text { direction: "out".into(), ..line("sms-12", NOW - 5 * HOUR - 10_000, "Merci.") };
2718 let reused = line("sms-12", NOW + 3 * DAY_MS, "Autre.");
2719 let bound = |t: &Text| messages(&conversation_id(&t.with), std::slice::from_ref(t), std::slice::from_ref(&asked), std::slice::from_ref(&bare), &v).into_iter().find(|m| m.id.starts_with("phone/")).map(|m| m.key).unwrap_or_default();
2720 assert_eq!(bound(&filed), "r2", "the text it sent, filed before its result");
2721 assert_eq!(bound(&reused), "", "a text received days later under its id");
2722 }
2723
2724 fn medium(n: u8, ct: &str) -> Medium {
2726 Medium { hash: format!("{n:02x}").repeat(32), ct: ct.into(), size: 4_096 }
2727 }
2728
2729 #[test]
2733 fn a_multimedia_request_is_refused_in_its_own_words() {
2734 let ledger = Ledger::with(Mark { since: 0, writers: 0 }, Vec::new());
2735 let mms = |media: Vec<Medium>, words: &str, also: Vec<String>, to: &str| Request { body: String::new(), words: words.into(), media, also, to: to.into(), ..request("m", "phone", NOW) };
2736 let v = |r: &Request| verdict(r, "phone", NOW, &ledger, fr(), &|_| Standing::Counted);
2737 let picture = || vec![medium(1, "image/jpeg")];
2738 assert_eq!(v(&mms(picture(), "Voilà l’entrée.", vec![], "01 99 00 12 34")), Verdict::Send);
2739 assert_eq!(v(&mms(picture(), "", vec![], "01 99 00 12 34")), Verdict::Send, "a picture alone");
2740 assert_eq!(v(&mms((1..=6).map(|n| medium(n, "image/png")).collect(), "", vec![], "01 99 00 12 34")), Verdict::Refused("too-many-media"));
2741 assert_eq!(v(&mms(vec![medium(2, "application/pdf")], "", vec![], "01 99 00 12 34")), Verdict::Refused("media-kind"), "another file goes by mail");
2742 assert_eq!(v(&mms(vec![Medium { hash: "abc".into(), ..medium(3, "image/jpeg") }], "", vec![], "01 99 00 12 34")), Verdict::Refused("media-kind"), "no content's hash");
2743 assert_eq!(v(&mms(vec![Medium { size: 0, ..medium(3, "image/jpeg") }], "", vec![], "01 99 00 12 34")), Verdict::Refused("media-kind"), "an empty one");
2744 for ct in ["video/mp4", "audio/amr", "text/x-vCard", "text/calendar"] {
2745 assert_eq!(v(&mms(vec![medium(4, ct)], "", vec![], "01 99 00 12 34")), Verdict::Send, "{ct}");
2746 }
2747 assert_eq!(v(&mms(picture(), "", vec!["04 65 71 00 42".into()], "01 99 00 12 34")), Verdict::Refused("several"), "a group: a later step");
2748 assert_eq!(v(&mms(picture(), "", vec![], "36179")), Verdict::Refused("short-number"));
2749 assert_eq!(v(&mms(picture(), &"x".repeat(WRITE_MAX + 1), vec![], "01 99 00 12 34")), Verdict::Refused("too-long"));
2750 let asked = mms(picture(), "Voilà l’entrée.", vec![], "01 99 00 12 34");
2752 assert_eq!(refusal(&asked.to, &asked.body, fr()), Some("empty"));
2753 let line = serde_json::to_value(&asked).unwrap();
2755 assert_eq!((line["body"].as_str(), line["words"].as_str(), line["media"].as_array().map(Vec::len)), (Some(""), Some("Voilà l’entrée."), Some(1)));
2756 let plain = serde_json::to_value(request("p", "phone", NOW)).unwrap();
2757 assert!(plain.get("media").is_none() && plain.get("words").is_none() && plain.get("also").is_none(), "a text's line as before: {plain}");
2758 let tr = Translator::new("en");
2760 let now = paris(NOW);
2761 let refused = Outcome { key: "m".into(), state: "refused".into(), at: NOW, reason: "empty".into(), ..Outcome::default() };
2762 let said_of = |media: bool| said(Some(&refused), None, &tr, &now, media);
2763 assert_eq!(said_of(false).text, "Not sent: it has no words.");
2764 assert!(said_of(true).text.contains("cannot send pictures yet") && said_of(true).again, "{}", said_of(true).text);
2765 }
2766
2767 #[test]
2774 fn a_multimedia_claim_is_looked_for_until_android_s_last_word() {
2775 let claim = Claim { key: "n1".into(), at: NOW, state: "handed".into(), mms: true, ..Claim::default() };
2776 assert!(doubt_after(&claim, None, NOW + 39 * MINUTE).is_none(), "Android may still be waiting for a network");
2777 let found = doubt_after(&claim, Some("mms-31@1791446400".into()), NOW + 41 * MINUTE).unwrap();
2778 assert_eq!((found.state.as_str(), found.row.as_str()), ("sent", "mms-31@1791446400"));
2779 assert!(doubt_after(&claim, None, NOW + 41 * MINUTE).is_none(), "not found at 41 minutes: Android may still try");
2780 assert!(doubt_after(&claim, None, NOW + 99 * MINUTE).is_none());
2781 assert_eq!(doubt_after(&claim, None, NOW + 100 * MINUTE).unwrap().state, "doubt", "Android's last word past");
2782 let tr = Translator::new("en");
2783 let handed = Outcome { key: "n1".into(), state: "handed".into(), at: NOW, ..Outcome::default() };
2784 assert_eq!(said(Some(&handed), None, &tr, &paris(NOW + 10 * MINUTE), true).text, "Your phone is sending it.");
2785 let still = said(Some(&handed), None, &tr, &paris(NOW + 41 * MINUTE), true);
2786 assert!(still.state == "sending" && !still.again && still.text.contains("11:40"), "until about 100 minutes after 10:00: {}", still.text);
2787 assert_eq!(said(Some(&handed), None, &tr, &paris(NOW + 41 * MINUTE), false).text, "Your phone is sending it.", "a text: as before");
2788 let past = said(Some(&handed), Some(NOW + 20 * MINUTE), &tr, &paris(NOW + 110 * MINUTE), true);
2790 assert!(past.state == "waiting" && past.text.contains("10:20") && !past.text.contains("11:40"), "{}", past.text);
2791 let text = Claim { mms: false, ..claim.clone() };
2792 assert!(doubt_after(&text, None, NOW + 3 * MINUTE).is_some(), "a text's, two minutes on as before");
2793 let line = serde_json::to_string(&Claim { mms: false, ..claim }).unwrap();
2794 assert!(!line.contains("mms"), "a text's claim as before: {line}");
2795 }
2796
2797 #[test]
2801 fn a_multimedia_result_says_the_carrier_s_answer() {
2802 let conf = |status: u8| crate::mms::Conf { status, ..crate::mms::Conf::default() };
2803 assert_eq!(took(-1, Some(&conf(crate::mms::OK)), 0), Took::Sent);
2804 assert_eq!(took(-1, None, 0), Took::Look, "Android's Ok, the carrier's answer missing");
2805 assert_eq!(took(4, None, 0), Took::Doubt("network"), "no HTTP status: an I/O failure, perhaps after the message was posted");
2807 assert_eq!(took(4, None, 302), Took::Doubt("network"), "an answer under 400: not a refusal");
2808 assert_eq!(took(0, None, 0), Took::Doubt("cancelled"));
2810 assert_eq!(took(1, None, 0), Took::Doubt("unexpected"));
2811 assert_eq!(took(6, None, 0), Took::Failed("mms-retry"));
2812 assert_eq!(took(13, None, 0), Took::Failed("mms-satellite"));
2813 assert_eq!(took(99, None, 0), Took::Doubt("unexpected"));
2814 assert_eq!(took(4, None, 404), Took::Failed("carrier-refused"));
2815 assert_eq!(took(4, None, 503), Took::Failed("carrier-refused"));
2816 let cases: [(i64, Option<u8>, &str); 14] = [
2817 (-1, Some(0xE5), "carrier-content"),
2818 (-1, Some(0xE1), "carrier-denied"),
2819 (-1, Some(0xEB), "carrier-credit"),
2820 (-1, Some(0xC3), "carrier-busy"),
2821 (-1, Some(0xE0), "carrier-refused"),
2822 (-1, Some(0x87), "carrier-content"),
2823 (5, None, "mms-unreadable"),
2824 (4, Some(0xC0), "carrier-busy"),
2825 (11, None, "mms-data-off"),
2826 (8, None, "mms-data-off"),
2827 (3, None, "mms-no-network"),
2828 (12, None, "mms-settings"),
2829 (5, None, "mms-unreadable"),
2830 (10, None, "no-sim"),
2831 ];
2832 for (code, status, why) in cases {
2833 assert_eq!(took(code, status.map(conf).as_ref(), 0), Took::Failed(why), "{code} {status:?}");
2834 }
2835 let tr = Translator::new("en");
2837 let now = paris(NOW);
2838 let other = said(Some(&Outcome { state: "failed".into(), reason: "other".into(), ..Outcome::default() }), None, &tr, &now, true).text;
2839 for (_, _, why) in cases {
2840 let words = said(Some(&Outcome { state: "failed".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2841 assert!(words.text != other && words.again, "{why}: {}", words.text);
2842 }
2843 let late = said(Some(&Outcome { state: "expired".into(), reason: "media".into(), ..Outcome::default() }), None, &tr, &now, true);
2844 assert!(late.text.contains("did not reach your phone") && late.again, "{}", late.text);
2845 for (why, again) in [("network", false), ("answer", false), ("cancelled", true), ("unexpected", true)] {
2847 let words = said(Some(&Outcome { state: "doubt".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2848 assert!(words.state == "doubt" && words.again == again && words.text != tr.text("texts-doubt", None), "{why}: {}", words.text);
2849 }
2850 assert!(said(Some(&Outcome { state: "doubt".into(), ..Outcome::default() }), None, &tr, &now, true).again);
2851 for why in ["mms-retry", "mms-satellite"] {
2852 let words = said(Some(&Outcome { state: "failed".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2853 assert!(words.text != other && words.again, "{why}: {}", words.text);
2854 }
2855 for why in ["too-large", "too-many-media", "media-kind", "no-mms"] {
2856 let words = said(Some(&Outcome { state: "refused".into(), reason: why.into(), ..Outcome::default() }), None, &tr, &now, true);
2857 assert!(words.text != tr.text("texts-refused-other", None) && !words.again, "{why}: {}", words.text);
2858 }
2859 }
2860
2861 #[test]
2865 fn a_phone_says_what_its_carriers_let_it_send() {
2866 let line = |at: i64, max: u64, device: &str| Outcome { key: String::new(), state: "limits".into(), at, limits: vec![Limits { sub: 3, max, width: 2592, height: 1944, recipients: 20, group: true, mms: true }], device: device.into(), ..Outcome::default() };
2867 let outcomes = vec![line(NOW - DAY_MS, 307_200, "phone"), line(NOW, 614_400, "phone"), line(NOW + MINUTE, 102_400, "other-phone"), Outcome { key: "k1".into(), state: "sent".into(), at: NOW, device: "phone".into(), ..Outcome::default() }];
2868 let (at, limits) = limits_of(&outcomes, "phone", NOW).unwrap();
2869 assert_eq!((at, limits[0].max), (NOW, 614_400));
2870 assert_eq!(limits_of(&outcomes, "third-phone", NOW), None);
2871 let ahead = [outcomes.clone(), vec![line(NOW + 365 * DAY_MS, 100 << 20, "phone")]].concat();
2873 assert_eq!(limits_of(&ahead, "phone", NOW).map(|(at, l)| (at, l[0].max)), Some((NOW, 614_400)));
2874 assert_eq!(limits_of(&ahead, "phone", NOW + 366 * DAY_MS).map(|(_, l)| l[0].max), Some(100 << 20), "once its date is past");
2875 assert_eq!(state_of("k1", &outcomes).map(|o| o.state.as_str()), Some("sent"), "a request's state never a limits line");
2876 assert_eq!(state_of("", &outcomes).map(|o| o.state.as_str()), None, "a limits line is no request's state");
2877 let written = serde_json::to_string(&outcomes[1]).unwrap();
2879 assert!(written.contains("\"limits\":[{"), "{written}");
2880 let old: Outcome = serde_json::from_str(r#"{"key":"k1","state":"sent","at":1}"#).unwrap();
2881 assert!(old.limits.is_empty());
2882 }
2883
2884 #[test]
2887 fn a_multimedia_request_shows_its_words_and_media() {
2888 let tr = Translator::new("en");
2889 let now = paris(NOW);
2890 let names = |_: &str| None;
2891 let picture = medium(7, "image/jpeg");
2892 let here_hash = picture.hash.clone();
2893 let here = move |hash: &str| hash == here_hash;
2894 let v = viewer(&now, &tr, &names, &here);
2895 let mms = Request { body: String::new(), words: "Voilà l’entrée.".into(), media: vec![picture.clone(), medium(8, "video/mp4")], ..request("q1", "phone", NOW - MINUTE) };
2896 let quiet = Request { words: String::new(), media: vec![picture], ..request("q2", "phone", NOW) };
2897 let id = conversation_id(&[phones::key(&mms.to, fr())]);
2898 let shown = messages(&id, &[], &[mms.clone(), quiet.clone()], &[], &v);
2899 assert_eq!(shown.len(), 2);
2900 assert_eq!((shown[0].body.as_str(), shown[0].picture, shown[0].parts.len()), ("Voilà l’entrée.", true, 2));
2901 assert_eq!((shown[0].parts[0].kind.as_str(), shown[0].parts[0].hash.is_empty()), ("picture", false), "the picture here");
2902 assert_eq!((shown[0].parts[1].kind.as_str(), shown[0].parts[1].hash.is_empty()), ("video", true), "the video not here yet: on its way");
2903 let list = conversations(&[], &[quiet], &v);
2904 assert_eq!(list[0].last, "a picture");
2905 }
2906}