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 MEDIA: &str = "media";
74pub const LEDGER: &str = "ledger.jsonl";
77pub const MARK: &str = "ledger-mark.json";
78pub const READER: &str = "reader.json";
79pub const RESULTS: &str = "results.jsonl";
80pub const KEPT_DAYS: i64 = 31;
82pub const LATE_DAYS: i64 = 14;
84pub const EXPIRY_MS: i64 = 15 * 60_000;
86pub const AHEAD_MS: i64 = 2 * 60_000;
88pub const LEDGER_KEPT_MS: i64 = 30 * DAY_MS;
90pub const LOOK_AFTER_MS: i64 = 2 * 60_000;
92pub const WRITE_MAX: usize = 1600;
94pub const PAD_STEP: usize = 256;
96pub const BATCH: usize = 500;
98pub const MEDIA_BATCH: u64 = 24 << 20;
100pub const MEDIA_CAP_MB: u64 = 10;
102const DAY_MS: i64 = 86_400_000;
103
104pub fn folder() -> PathBuf {
106 crate::config::data_dir().join(FOLDER)
107}
108
109pub fn private_folder() -> PathBuf {
111 crate::config::state_dir().join(FOLDER)
112}
113
114pub trait Sealer {
116 fn seal(&self, plain: &str) -> String;
118 fn open(&self, sealed: &str) -> Option<String>;
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126pub struct Part {
127 #[serde(default)]
129 pub seq: i64,
130 #[serde(default)]
132 pub ct: String,
133 #[serde(default, skip_serializing_if = "String::is_empty")]
135 pub name: String,
136 #[serde(default, skip_serializing_if = "is_zero_u64")]
138 pub size: u64,
139 #[serde(default, skip_serializing_if = "String::is_empty")]
141 pub text: String,
142 #[serde(default, skip_serializing_if = "String::is_empty")]
144 pub hash: String,
145 #[serde(default)]
147 pub state: String,
148 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
150 pub fields: BTreeMap<String, Value>,
151}
152
153fn is_zero_u64(value: &u64) -> bool {
154 *value == 0
155}
156
157#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
159pub struct Text {
160 pub id: String,
162 pub at: i64,
164 #[serde(default, skip_serializing_if = "String::is_empty")]
166 pub thread: String,
167 #[serde(rename = "box")]
169 pub direction: String,
170 #[serde(default)]
172 pub with: Vec<String>,
173 #[serde(default, skip_serializing_if = "String::is_empty")]
175 pub from: String,
176 #[serde(default, skip_serializing_if = "String::is_empty")]
177 pub body: String,
178 #[serde(default = "no_sim")]
180 pub sub: i32,
181 #[serde(default, skip_serializing_if = "is_false")]
183 pub picture: bool,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub parts: Vec<Part>,
187 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189 pub fields: BTreeMap<String, Value>,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub addrs: Vec<BTreeMap<String, Value>>,
193 #[serde(default, skip_serializing_if = "String::is_empty")]
195 pub key: String,
196 #[serde(default, skip_serializing_if = "is_zero")]
198 pub deleted: i64,
199 #[serde(skip)]
201 pub device: String,
202}
203
204fn no_sim() -> i32 {
205 -1
206}
207
208fn is_false(value: &bool) -> bool {
209 !*value
210}
211
212fn is_zero(value: &i64) -> bool {
213 *value == 0
214}
215
216impl Text {
217 pub fn line_id(&self) -> String {
219 if self.deleted > 0 { format!("{}#deleted", self.id) } else { self.id.clone() }
220 }
221
222 pub fn media_bytes(&self) -> u64 {
224 self.parts.iter().filter(|p| p.state == "here").map(|p| p.size).sum()
225 }
226}
227
228#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct Request {
231 pub key: String,
233 pub phone: String,
235 pub to: String,
237 pub body: String,
238 #[serde(default = "no_sim")]
240 pub sub: i32,
241 pub written: i64,
243 #[serde(default, skip_serializing_if = "is_zero")]
248 pub ahead: i64,
249 #[serde(default, skip_serializing_if = "String::is_empty")]
251 pub again: String,
252 #[serde(skip)]
254 pub device: String,
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
259pub struct Outcome {
260 pub key: String,
261 pub state: String,
264 pub at: i64,
266 #[serde(default, skip_serializing_if = "String::is_empty")]
269 pub reason: String,
270 #[serde(default, skip_serializing_if = "String::is_empty")]
272 pub row: String,
273 #[serde(skip)]
275 pub device: String,
276}
277
278pub fn written<T: Serialize>(value: &T, sealer: &dyn Sealer) -> String {
283 let text = serde_json::to_string(value).unwrap_or_default();
284 sealer.seal(&padded(&text))
285}
286
287pub fn padded(json: &str) -> String {
289 let Some(body) = json.strip_suffix('}') else { return json.to_string() };
290 let joint = if body.ends_with('{') { "" } else { "," };
291 let bare = format!("{body}{joint}\"pad\":\"\"}}");
292 let spaces = (PAD_STEP - bare.len() % PAD_STEP) % PAD_STEP;
293 format!("{body}{joint}\"pad\":\"{}\"}}", " ".repeat(spaces))
294}
295
296pub fn file_name(device: &str) -> String {
298 calls::file_safe(device)
299}
300
301pub fn time_of_day(at_ms: i64, now: &Zoned) -> String {
303 calls::local(at_ms, now).strftime("%H:%M").to_string()
304}
305
306pub fn own_file(root: &Path, store: &str, device: &str) -> PathBuf {
308 calls::own_file(root, store, device)
309}
310
311pub fn read_file<T: for<'de> Deserialize<'de>>(path: &Path, sealer: &dyn Sealer) -> Vec<T> {
313 std::fs::read_to_string(path).unwrap_or_default().lines().filter(|l| !l.trim().is_empty()).filter_map(|l| sealer.open(l.trim())).filter_map(|plain| serde_json::from_str(&plain).ok()).collect()
314}
315
316fn read_store<T: for<'de> Deserialize<'de>>(root: &Path, store: &str, sealer: &dyn Sealer, mut set: impl FnMut(&mut T, &str)) -> Vec<T> {
318 let mut all = Vec::new();
319 for (device, path) in calls::device_files(&root.join(store)) {
320 for mut value in read_file::<T>(&path, sealer) {
321 set(&mut value, &device);
322 all.push(value);
323 }
324 }
325 all
326}
327
328pub fn read_logs(root: &Path, sealer: &dyn Sealer) -> Vec<Text> {
331 let lines: Vec<Text> = read_store(root, LOG, sealer, |t: &mut Text, d| t.device = d.to_string());
332 let deletions: BTreeMap<(String, String), i64> = lines.iter().filter(|t| t.deleted > 0).map(|t| ((t.device.clone(), t.id.clone()), t.deleted)).collect();
333 let mut seen = BTreeSet::new();
334 let mut texts: Vec<Text> = lines
335 .into_iter()
336 .filter(|t| t.deleted == 0 && t.at > 0 && seen.insert((t.device.clone(), t.id.clone())))
337 .map(|t| {
338 let deleted = deletions.get(&(t.device.clone(), t.id.clone())).copied().unwrap_or(0);
339 Text { deleted, ..t }
340 })
341 .collect();
342 texts.sort_by(|a, b| (a.at, &a.device, &a.id).cmp(&(b.at, &b.device, &b.id)));
343 texts
344}
345
346pub fn read_requests(root: &Path, sealer: &dyn Sealer) -> Vec<Request> {
347 read_store(root, SEND, sealer, |r: &mut Request, d| r.device = d.to_string())
348}
349
350pub fn read_outcomes(root: &Path, sealer: &dyn Sealer) -> Vec<Outcome> {
351 let mut all: Vec<Outcome> = read_store(root, OUTCOME, sealer, |o: &mut Outcome, d| o.device = d.to_string());
352 all.sort_by_key(|o| o.at);
353 all
354}
355
356pub 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> {
360 if values.is_empty() {
361 return Ok(0);
362 }
363 crate::filelock::with_lock(own, || {
364 let mut known: BTreeSet<String> = match id_of {
365 Some(id) => read_file::<T>(own, sealer).iter().map(id).collect(),
366 None => BTreeSet::new(),
367 };
368 let mut text = String::new();
369 let mut count = 0;
370 for value in values {
371 if let Some(id) = id_of
372 && !known.insert(id(value))
373 {
374 continue;
375 }
376 text.push_str(&written(value, sealer));
377 text.push('\n');
378 count += 1;
379 }
380 if count > 0 {
381 calls::append_private(own, &text)?;
382 }
383 Ok(count)
384 })
385}
386
387fn time_of(sealer: &dyn Sealer, line: &str) -> Option<i64> {
389 let value: Value = serde_json::from_str(&sealer.open(line.trim())?).ok()?;
390 value["at"].as_i64().or_else(|| value["written"].as_i64())
391}
392
393pub fn trim_own(root: &Path, device: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
396 let mut gone = 0;
397 for store in [SEND, OUTCOME] {
398 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))?;
399 }
400 Ok(gone)
401}
402
403pub fn trim_others(root: &Path, here: &str, sealer: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
405 let mut gone = 0;
406 for store in [SEND, OUTCOME] {
407 for (device, path) in calls::device_files(&root.join(store)) {
408 if device != calls::file_safe(here) {
409 gone += calls::rewrite_lines(&path, |line| time_of(sealer, line).is_some_and(|at| now_ms - at > (KEPT_DAYS + LATE_DAYS) * DAY_MS))?;
410 }
411 }
412 }
413 Ok(gone)
414}
415
416pub fn kept(texts: &[Text], media_here: &dyn Fn(&str) -> bool) -> (usize, u64) {
418 let mut hashes = BTreeSet::new();
419 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();
420 (texts.len(), bytes)
421}
422
423#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
427pub struct Claim {
428 pub key: String,
429 pub at: i64,
431 pub state: String,
435 #[serde(default, skip_serializing_if = "String::is_empty")]
437 pub to: String,
438 #[serde(default, skip_serializing_if = "String::is_empty")]
439 pub hash: String,
440}
441
442#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
444pub struct Mark {
445 pub since: i64,
446}
447
448#[derive(Debug, Clone, Default, PartialEq, Eq)]
450pub struct Ledger {
451 pub mark: Mark,
452 pub claims: Vec<Claim>,
453}
454
455impl Ledger {
456 pub fn open(dir: &Path, now_ms: i64) -> Result<Ledger, String> {
460 let mark_path = dir.join(MARK);
461 let mark = match std::fs::read_to_string(&mark_path).ok().and_then(|t| serde_json::from_str::<Mark>(&t).ok()) {
462 Some(mark) => mark,
463 None => {
464 let mark = Mark { since: now_ms };
465 write_flushed(&mark_path, &serde_json::to_string(&mark).map_err(|e| e.to_string())?, false)?;
466 mark
467 }
468 };
469 let claims = std::fs::read_to_string(dir.join(LEDGER)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
470 Ok(Ledger { mark, claims })
471 }
472
473 pub fn has(&self, key: &str) -> bool {
475 self.claims.iter().any(|c| c.key == key)
476 }
477
478 pub fn state(&self, key: &str) -> Option<&str> {
480 self.claims.iter().rev().find(|c| c.key == key).map(|c| c.state.as_str())
481 }
482}
483
484pub fn record(dir: &Path, claim: &Claim) -> Result<(), String> {
487 let line = serde_json::to_string(claim).map_err(|e| e.to_string())? + "\n";
488 write_flushed(&dir.join(LEDGER), &line, true)
489}
490
491fn write_flushed(path: &Path, text: &str, append: bool) -> Result<(), String> {
493 use std::io::Write;
494 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
495 if let Some(dir) = path.parent() {
496 std::fs::create_dir_all(dir).map_err(fail)?;
497 }
498 let mut options = std::fs::OpenOptions::new();
499 options.create(true).write(true);
500 if append {
501 options.append(true);
502 } else {
503 options.truncate(true);
504 }
505 #[cfg(unix)]
506 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
507 let mut file = options.open(path).map_err(fail)?;
508 file.write_all(text.as_bytes()).map_err(fail)?;
509 file.sync_all().map_err(fail)
510}
511
512pub fn trim_ledger(dir: &Path, now_ms: i64) -> Result<usize, String> {
515 calls::rewrite_lines(&dir.join(LEDGER), |line| serde_json::from_str::<Claim>(line).is_ok_and(|c| now_ms - c.at > LEDGER_KEPT_MS))
516}
517
518pub fn words_hash(body: &str) -> String {
520 crate::appnotes::talk_key("texts", body)
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum Verdict {
528 Send,
530 NotMine,
532 Decided,
534 BeforeMark,
536 Expired,
538 Clocks,
540 Refused(&'static str),
542}
543
544pub fn refusal(to: &str, body: &str, region: Option<&Region>) -> Option<&'static str> {
548 if body.trim().is_empty() {
549 return Some("empty");
550 }
551 if body.chars().count() > WRITE_MAX {
552 return Some("too-long");
553 }
554 if to.contains([',', ';']) || to.chars().filter(char::is_ascii_digit).count() > 15 {
555 return Some("several");
556 }
557 let key = phones::key(to, region);
558 if !phones::is_whole(&key) || key.trim_start_matches('+').len() < 8 {
559 return Some("short-number");
560 }
561 None
562}
563
564pub fn verdict(request: &Request, here: &str, now_ms: i64, ledger: &Ledger, region: Option<&Region>) -> Verdict {
571 if request.phone != here || request.key.is_empty() {
572 return Verdict::NotMine;
573 }
574 if ledger.has(&request.key) {
575 return Verdict::Decided;
576 }
577 if let Some(why) = refusal(&request.to, &request.body, region) {
578 return Verdict::Refused(why);
579 }
580 let written = request.written - request.ahead;
581 if written < ledger.mark.since {
582 return Verdict::BeforeMark;
583 }
584 if written > now_ms + AHEAD_MS {
585 return Verdict::Clocks;
586 }
587 if now_ms - written > EXPIRY_MS {
588 return Verdict::Expired;
589 }
590 Verdict::Send
591}
592
593pub fn doubt_after(claim: &Claim, found: Option<String>, now_ms: i64) -> Option<Outcome> {
598 if now_ms - claim.at < LOOK_AFTER_MS {
599 return None;
600 }
601 Some(match found {
602 Some(row) => Outcome { key: claim.key.clone(), state: "sent".into(), at: now_ms, reason: String::new(), row, device: String::new() },
603 None => Outcome { key: claim.key.clone(), state: "doubt".into(), at: now_ms, reason: String::new(), row: String::new(), device: String::new() },
604 })
605}
606
607pub fn new_key() -> String {
609 use std::io::Read;
610 let mut bytes = [0u8; 16];
611 let read = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes));
612 if read.is_err() {
613 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
614 bytes = (now ^ u128::from(std::process::id()).rotate_left(64)).to_le_bytes();
615 }
616 bytes.iter().map(|b| format!("{b:02x}")).collect()
617}
618
619const GSM_BASIC: &str = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
623const GSM_EXTENDED: &str = "^{}\\[~]|€\u{c}";
625
626pub fn parts(body: &str) -> (usize, bool) {
630 let gsm = body.chars().all(|c| GSM_BASIC.contains(c) || GSM_EXTENDED.contains(c));
631 if gsm {
632 let septets: usize = body.chars().map(|c| if GSM_EXTENDED.contains(c) { 2 } else { 1 }).sum();
633 (if septets <= 160 { usize::from(septets > 0) } else { septets.div_ceil(153) }, true)
634 } else {
635 let units: usize = body.chars().map(char::len_utf16).sum();
636 (if units <= 70 { 1 } else { units.div_ceil(67) }, false)
637 }
638}
639
640#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
644pub struct Said {
645 pub state: String,
647 pub text: String,
649 pub again: bool,
651}
652
653pub fn state_of<'a>(key: &str, outcomes: &'a [Outcome]) -> Option<&'a Outcome> {
655 let rank = |s: &str| match s {
656 "taken" => 1,
657 "handed" => 2,
658 "doubt" => 3,
659 "sent" => 4,
660 "delivered" => 5,
661 "failed" | "expired" | "refused" | "clocks" => 6,
662 _ => 0,
663 };
664 outcomes.iter().filter(|o| o.key == key).max_by_key(|o| (rank(&o.state), o.at))
665}
666
667pub fn said(outcome: Option<&Outcome>, phone_shared: Option<i64>, tr: &Translator, now: &Zoned) -> Said {
671 let time = |ms: i64| calls::local(ms, now).strftime("%H:%M").to_string();
672 let with = |id: &str, key: &str, value: String| {
673 let mut args = crate::i18n::args();
674 args.set(key, value);
675 tr.text(id, Some(&args))
676 };
677 let (state, text, again) = match outcome {
678 None => match phone_shared {
679 Some(at) => ("waiting", with("texts-waiting-since", "time", time(at)), false),
680 None => ("waiting", tr.text("texts-waiting", None), false),
681 },
682 Some(o) => match o.state.as_str() {
683 "taken" | "handed" => ("sending", tr.text("texts-sending", None), false),
684 "sent" => ("sent", with("texts-sent-at", "time", time(o.at)), false),
685 "delivered" => ("delivered", with("texts-delivered-at", "time", time(o.at)), false),
686 "failed" => ("failed", with("texts-failed", "why", tr.text(&format!("texts-why-{}", known_reason(&o.reason)), None)), true),
687 "expired" => ("expired", tr.text("texts-expired", None), true),
688 "refused" => ("refused", tr.text(&format!("texts-refused-{}", known_refusal(&o.reason)), None), o.reason == "before-mark"),
689 "clocks" => ("clocks", tr.text("texts-clocks", None), true),
690 _ => ("doubt", tr.text("texts-doubt", None), true),
691 },
692 };
693 Said { state: state.into(), text, again }
694}
695
696fn known_reason(reason: &str) -> &'static str {
698 match reason {
699 "radio-off" => "radio-off",
700 "no-service" => "no-service",
701 "limit" => "limit",
702 "no-sim" => "no-sim",
703 _ => "other",
704 }
705}
706
707fn known_refusal(reason: &str) -> &'static str {
709 match reason {
710 "short-number" => "short-number",
711 "several" => "several",
712 "empty" => "empty",
713 "too-long" => "too-long",
714 "before-mark" => "before-mark",
715 _ => "other",
716 }
717}
718
719#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
721pub struct Conversation {
722 pub id: String,
724 pub with: Vec<String>,
725 pub title: String,
727 pub last: String,
729 pub when: String,
730 pub at: i64,
731 pub group: bool,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
736pub struct Shown {
737 pub kind: String,
739 pub name: String,
740 pub said: String,
742 pub hash: String,
744 pub ct: String,
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
749pub struct Message {
750 pub id: String,
751 pub at: i64,
752 pub when: String,
754 pub day: String,
757 pub time: String,
758 pub direction: String,
760 pub from: String,
762 pub body: String,
763 pub picture: bool,
764 pub parts: Vec<Shown>,
765 pub deleted: bool,
767 pub key: String,
769 pub said: Option<Said>,
770 pub to: String,
772}
773
774pub struct Viewer<'a> {
776 pub now: &'a Zoned,
777 pub tr: &'a Translator,
778 pub region: Option<&'static Region>,
779 pub name_of: &'a dyn Fn(&str) -> Option<String>,
781 pub phone_shared: Option<i64>,
783 pub media_here: &'a dyn Fn(&str) -> bool,
785}
786
787pub fn conversation_id(with: &[String]) -> String {
789 let mut keys: Vec<&str> = with.iter().map(String::as_str).filter(|k| !k.is_empty()).collect();
790 keys.sort_unstable();
791 keys.dedup();
792 keys.join(",")
793}
794
795fn title_of(with: &[String], v: &Viewer) -> String {
797 with.iter().map(|k| (v.name_of)(k).unwrap_or_else(|| calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ")
798}
799
800fn day_and_time(at: i64, v: &Viewer) -> (String, String) {
803 let local = calls::local(at, v.now);
804 let mut day = calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date()));
805 if local.year() != v.now.year() && local.date() < v.now.date().yesterday().unwrap_or(local.date()) {
806 day = format!("{day} {}", local.year());
807 }
808 (day, local.strftime("%H:%M").to_string())
809}
810
811fn when_words(at: i64, v: &Viewer) -> String {
813 let local = calls::local(at, v.now);
814 let time = local.strftime("%H:%M").to_string();
815 if local.date() == v.now.date() {
816 time
817 } else {
818 format!("{} {time}", calls::capitalized(&calls::day_words(v.tr, local.date(), v.now.date())))
819 }
820}
821
822fn kind_of(ct: &str) -> &'static str {
824 match ct.split('/').next().unwrap_or("") {
825 "image" => "picture",
826 "audio" => "sound",
827 "video" => "video",
828 "text" if ct.contains("vcard") || ct.contains("x-vcard") => "card",
829 "text" => "text",
830 _ if ct.contains("vcard") => "card",
831 _ => "file",
832 }
833}
834
835fn shown(part: &Part, v: &Viewer) -> Option<Shown> {
839 if part.ct == "application/smil" || part.state == "text" || (kind_of(&part.ct) == "text" && part.hash.is_empty()) {
840 return None;
841 }
842 let kind = kind_of(&part.ct);
843 let size = crate::view::size(v.tr, usize::try_from(part.size).unwrap_or(usize::MAX));
844 let mut args = crate::i18n::args();
845 args.set("size", size);
846 let (said, hash) = match part.state.as_str() {
847 "too-big" => (v.tr.text("texts-part-too-big", Some(&args)), String::new()),
848 "not-downloaded" => (v.tr.text("texts-part-not-downloaded", None), String::new()),
849 _ if !(v.media_here)(&part.hash) => (v.tr.text("texts-part-coming", Some(&args)), String::new()),
850 _ => (v.tr.text(&format!("texts-part-{kind}"), Some(&args)), part.hash.clone()),
851 };
852 Some(Shown { kind: kind.into(), name: part.name.clone(), said, hash, ct: part.ct.clone() })
853}
854
855pub fn conversations(texts: &[Text], requests: &[Request], v: &Viewer) -> Vec<Conversation> {
857 let mut by: BTreeMap<String, Conversation> = BTreeMap::new();
858 let mut add = |with: Vec<String>, at: i64, last: String| {
859 let id = conversation_id(&with);
860 if id.is_empty() {
861 return;
862 }
863 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 });
864 if at >= entry.at {
865 entry.at = at;
866 entry.last = last;
867 entry.when = when_words(at, v);
868 }
869 };
870 for t in texts {
871 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() };
872 add(t.with.clone(), t.at, first);
873 }
874 for r in requests {
875 add(vec![phones::key(&r.to, v.region)], r.written, r.body.chars().take(80).collect());
876 }
877 let mut list: Vec<Conversation> = by.into_values().collect();
878 list.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
879 list
880}
881
882pub fn messages(id: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer) -> Vec<Message> {
886 let key_of_row: BTreeMap<&str, &str> = outcomes.iter().filter(|o| !o.row.is_empty()).map(|o| (o.row.as_str(), o.key.as_str())).collect();
887 let mut out: Vec<Message> = Vec::new();
888 let mut shown_keys = BTreeSet::new();
889 for t in texts.iter().filter(|t| conversation_id(&t.with) == id) {
890 let key = if t.key.is_empty() { key_of_row.get(t.id.as_str()).map(|k| k.to_string()).unwrap_or_default() } else { t.key.clone() };
891 let said = (!key.is_empty() && requests.iter().any(|r| r.key == key)).then(|| self::said(state_of(&key, outcomes), v.phone_shared, v.tr, v.now));
892 if !key.is_empty() {
893 shown_keys.insert(key.clone());
894 }
895 let (day, time) = day_and_time(t.at, v);
896 out.push(Message {
897 id: format!("{}/{}", t.device, t.id),
898 at: t.at,
899 when: when_words(t.at, v),
900 day,
901 time,
902 direction: t.direction.clone(),
903 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() },
904 body: t.body.clone(),
905 picture: t.picture,
906 parts: t.parts.iter().filter_map(|p| shown(p, v)).collect(),
907 deleted: t.deleted > 0,
908 key,
909 said,
910 to: String::new(),
911 });
912 }
913 for r in requests.iter().filter(|r| conversation_id(&[phones::key(&r.to, v.region)]) == id && !shown_keys.contains(&r.key)) {
914 let outcome = state_of(&r.key, outcomes);
915 let (day, time) = day_and_time(r.written, v);
916 out.push(Message {
917 id: format!("request/{}", r.key),
918 at: r.written,
919 when: when_words(r.written, v),
920 day,
921 time,
922 direction: "out".into(),
923 from: String::new(),
924 body: r.body.clone(),
925 picture: false,
926 parts: Vec::new(),
927 deleted: false,
928 key: r.key.clone(),
929 said: Some(said(outcome, v.phone_shared, v.tr, v.now)),
930 to: r.to.clone(),
931 });
932 }
933 out.sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id)));
934 out
935}
936
937#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
939pub struct Found {
940 pub conversation: String,
942 pub title: String,
943 pub message: Message,
944}
945
946pub fn search(query: &str, texts: &[Text], requests: &[Request], outcomes: &[Outcome], v: &Viewer, limit: usize) -> Vec<Found> {
950 let words: Vec<String> = crate::rules::fold(query).split(' ').filter(|w| !w.is_empty()).map(str::to_string).collect();
951 if words.is_empty() {
952 return Vec::new();
953 }
954 let mut people: BTreeMap<String, (String, String)> = BTreeMap::new();
956 let mut found: Vec<&Text> = texts
957 .iter()
958 .filter(|t| {
959 let id = conversation_id(&t.with);
960 let (_, said) = people.entry(id).or_insert_with(|| {
961 let title = title_of(&t.with, v);
962 let numbers = t.with.iter().map(|k| format!("{k} {}", calls::shown_number(k, v.region))).collect::<Vec<_>>().join(" ");
963 let said = crate::rules::fold(&format!("{title} {numbers}"));
964 (title, said)
965 });
966 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(" "));
967 words.iter().all(|w| own.contains(w.as_str()) || said.contains(w.as_str()))
968 })
969 .collect();
970 found.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.id.cmp(&b.id)));
971 found.truncate(limit);
972 found
973 .into_iter()
974 .filter_map(|t| {
975 let id = conversation_id(&t.with);
976 let row = format!("{}/{}", t.device, t.id);
977 let message = messages(&id, std::slice::from_ref(t), requests, outcomes, v).into_iter().find(|m| m.id == row)?;
978 Some(Found { title: people.get(&id).map(|(title, _)| title.clone()).unwrap_or_default(), conversation: id, message })
979 })
980 .collect()
981}
982
983pub fn last_sim(id: &str, phone: &str, texts: &[Text]) -> i32 {
985 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)
986}
987
988pub const PART_KINDS: [&str; 6] = ["picture", "sound", "video", "card", "file", "text"];
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 struct Stand;
997
998 impl Sealer for Stand {
999 fn seal(&self, plain: &str) -> String {
1000 format!("sealed:{}", plain.bytes().map(|b| format!("{:02x}", b ^ 0x5a)).collect::<String>())
1001 }
1002 fn open(&self, sealed: &str) -> Option<String> {
1003 let hex = sealed.strip_prefix("sealed:")?;
1004 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();
1005 String::from_utf8(bytes?).ok()
1006 }
1007 }
1008
1009 const NOW: i64 = 1_791_446_400_000;
1011 const MINUTE: i64 = 60_000;
1012
1013 fn fr() -> Option<&'static Region> {
1014 phones::region_named("FR")
1015 }
1016
1017 fn scratch(name: &str) -> PathBuf {
1018 let dir = std::env::temp_dir().join(format!("sioul-texts-{name}-{}", std::process::id()));
1019 let _ = std::fs::remove_dir_all(&dir);
1020 dir
1021 }
1022
1023 fn request(key: &str, phone: &str, written: i64) -> Request {
1024 Request { key: key.into(), phone: phone.into(), to: "01 99 00 12 34".into(), body: "J'arrive dans dix minutes.".into(), sub: -1, written, ahead: 0, again: String::new(), device: "desk".into() }
1025 }
1026
1027 fn step(dir: &Path, requests: &[Request], here: &str, now: i64, ahead: i64) -> Vec<String> {
1030 let ledger = Ledger::open(dir, now).unwrap();
1031 let mut sent = Vec::new();
1032 for r in requests {
1033 if verdict(&Request { ahead, ..r.clone() }, here, now, &ledger, fr()) == Verdict::Send {
1034 record(dir, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, fr()), hash: words_hash(&r.body) }).unwrap();
1035 sent.push(r.key.clone());
1036 }
1037 }
1038 sent
1039 }
1040
1041 #[test]
1042 fn a_request_is_sent_once_whatever_is_read_again() {
1043 let dir = scratch("once");
1044 Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
1045 let r = request("a1", "phone", NOW - MINUTE);
1046 assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW, 0), ["a1"]);
1047 for later in [NOW + MINUTE, NOW + 2 * MINUTE] {
1048 assert!(step(&dir, &[r.clone(), r.clone()], "phone", later, 0).is_empty());
1049 }
1050 assert_eq!(Ledger::open(&dir, NOW).unwrap().state("a1"), Some("claimed"));
1051 let _ = std::fs::remove_dir_all(&dir);
1052 }
1053
1054 #[test]
1055 fn a_lost_ledger_s_mark_refuses_the_requests_written_before_it() {
1056 let dir = scratch("lost");
1057 Ledger::open(&dir, NOW - 10 * MINUTE).unwrap();
1058 let r = request("b1", "phone", NOW - 3 * MINUTE);
1059 assert_eq!(step(&dir, std::slice::from_ref(&r), "phone", NOW - 2 * MINUTE, 0), ["b1"]);
1060 std::fs::remove_dir_all(&dir).unwrap();
1061 let ledger = Ledger::open(&dir, NOW).unwrap();
1062 assert_eq!(verdict(&r, "phone", NOW, &ledger, fr()), Verdict::BeforeMark, "within its 15 minutes, still never again");
1063 assert!(step(&dir, &[r], "phone", NOW, 0).is_empty());
1064 assert_eq!(step(&dir, &[request("b2", "phone", NOW + MINUTE)], "phone", NOW + MINUTE, 0), ["b2"]);
1065 let _ = std::fs::remove_dir_all(&dir);
1066 }
1067
1068 #[test]
1069 fn two_phones_each_send_only_their_own_and_two_computers_both_send() {
1070 let (a, b) = (scratch("phone-a"), scratch("phone-b"));
1071 Ledger::open(&a, NOW - 10 * MINUTE).unwrap();
1072 Ledger::open(&b, NOW - 10 * MINUTE).unwrap();
1073 let from_desk = request("c1", "phone-a", NOW - MINUTE);
1074 let from_laptop = Request { device: "laptop".into(), ..request("c2", "phone-a", NOW - MINUTE) };
1075 let to_b = request("c3", "phone-b", NOW - MINUTE);
1076 let all = [from_desk, from_laptop, to_b];
1077 assert_eq!(step(&a, &all, "phone-a", NOW, 0), ["c1", "c2"], "two computers, two keys, two texts");
1078 assert_eq!(step(&b, &all, "phone-b", NOW, 0), ["c3"]);
1079 let _ = (std::fs::remove_dir_all(&a), std::fs::remove_dir_all(&b));
1080 }
1081
1082 #[test]
1083 fn an_expired_request_and_clocks_apart() {
1084 let dir = scratch("expired");
1085 Ledger::open(&dir, NOW - DAY_MS).unwrap();
1086 let ledger = Ledger::open(&dir, NOW).unwrap();
1087 assert_eq!(verdict(&request("d1", "phone", NOW - 16 * MINUTE), "phone", NOW, &ledger, fr()), Verdict::Expired);
1088 assert_eq!(verdict(&request("d2", "phone", NOW - 14 * MINUTE), "phone", NOW, &ledger, fr()), Verdict::Send);
1089 let ahead = 60 * MINUTE;
1091 assert_eq!(verdict(&Request { ahead, ..request("d3", "phone", NOW + ahead - 20 * MINUTE) }, "phone", NOW, &ledger, fr()), Verdict::Expired);
1092 assert_eq!(verdict(&Request { ahead, ..request("d4", "phone", NOW + ahead - 5 * MINUTE) }, "phone", NOW, &ledger, fr()), Verdict::Send);
1093 assert_eq!(verdict(&request("d5", "phone", NOW + 30 * MINUTE), "phone", NOW, &ledger, fr()), Verdict::Clocks);
1095 let _ = std::fs::remove_dir_all(&dir);
1096 }
1097
1098 #[test]
1099 fn short_numbers_groups_and_empty_words_are_refused() {
1100 let ledger = Ledger { mark: Mark { since: 0 }, claims: Vec::new() };
1101 let with = |to: &str, body: &str| verdict(&Request { to: to.into(), body: body.into(), ..request("e", "phone", NOW) }, "phone", NOW, &ledger, fr());
1102 assert_eq!(with("36179", "STOP"), Verdict::Refused("short-number"));
1103 assert_eq!(with("01 99 00 12 34, 04 65 71 00 42", "Bonjour"), Verdict::Refused("several"));
1104 assert_eq!(with("01 99 00 12 34", " "), Verdict::Refused("empty"));
1105 assert_eq!(with("01 99 00 12 34", &"x".repeat(WRITE_MAX + 1)), Verdict::Refused("too-long"));
1106 assert_eq!(with("+33 1 99 00 12 34", "Bonjour"), Verdict::Send);
1107 }
1108
1109 #[test]
1110 fn a_crash_between_the_claim_and_the_result_is_said_never_sent_again() {
1111 let claim = Claim { key: "f1".into(), at: NOW, state: "claimed".into(), to: "+33199001234".into(), hash: words_hash("Bonjour") };
1112 assert!(doubt_after(&claim, None, NOW + MINUTE).is_none(), "Android's word may still come");
1113 let found = doubt_after(&claim, Some("sms-412".into()), NOW + 3 * MINUTE).unwrap();
1114 assert_eq!((found.state.as_str(), found.row.as_str()), ("sent", "sms-412"));
1115 assert_eq!(doubt_after(&claim, None, NOW + 3 * MINUTE).unwrap().state, "doubt");
1116 let ledger = Ledger { mark: Mark { since: 0 }, claims: vec![claim] };
1117 assert_eq!(verdict(&request("f1", "phone", NOW + 3 * MINUTE), "phone", NOW + 3 * MINUTE, &ledger, fr()), Verdict::Decided);
1118 }
1119
1120 #[test]
1121 fn the_ledger_is_flushed_before_android_is_asked_and_trimmed_after_a_month() {
1122 let dir = scratch("ledger");
1123 record(&dir, &Claim { key: "g1".into(), at: NOW - 31 * DAY_MS, state: "done".into(), ..Claim::default() }).unwrap();
1124 record(&dir, &Claim { key: "g2".into(), at: NOW, state: "claimed".into(), ..Claim::default() }).unwrap();
1125 assert!(std::fs::read_to_string(dir.join(LEDGER)).unwrap().contains("\"g2\""));
1126 assert_eq!(trim_ledger(&dir, NOW).unwrap(), 1);
1127 let ledger = Ledger::open(&dir, NOW).unwrap();
1128 assert!(!ledger.has("g1") && ledger.has("g2"));
1129 let _ = std::fs::remove_dir_all(&dir);
1130 }
1131
1132 fn message(id: &str, at: i64, body: &str) -> Text {
1133 Text { id: id.into(), at, thread: "7".into(), direction: "in".into(), with: vec!["+33199001234".into()], body: body.into(), sub: 1, ..Text::default() }
1134 }
1135
1136 #[test]
1137 fn the_history_is_sealed_padded_kept_whole_and_a_deletion_marks_it() {
1138 let dir = scratch("lines");
1139 let own = own_file(&dir, LOG, "phone");
1140 let id = |t: &Text| t.line_id();
1141 let old = NOW - 3 * 365 * DAY_MS;
1142 assert_eq!(append(&own, &[message("sms-1", old, "Bonjour"), message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 2);
1143 assert_eq!(append(&own, &[message("sms-2", NOW, "Votre rendez-vous est déplacé.")], &Stand, Some(&id)).unwrap(), 0, "read again: once");
1144 let raw = std::fs::read_to_string(&own).unwrap();
1145 assert!(!raw.contains("Bonjour") && !raw.contains("199001234"), "sealed at rest");
1146 for line in raw.lines() {
1147 assert_eq!(Stand.open(line).unwrap().len() % PAD_STEP, 0);
1148 }
1149 let later = NOW + 400 * DAY_MS;
1151 assert_eq!(trim_own(&dir, "phone", &Stand, later).unwrap() + trim_others(&dir, "desk", &Stand, later).unwrap(), 0);
1152 assert_eq!(read_logs(&dir, &Stand).len(), 2);
1153 let deletion = Text { deleted: NOW + MINUTE, ..message("sms-1", old, "") };
1155 assert_eq!(append(&own, &[deletion], &Stand, Some(&id)).unwrap(), 1);
1156 let back = read_logs(&dir, &Stand);
1157 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)]);
1158 let _ = std::fs::remove_dir_all(&dir);
1159 }
1160
1161 #[test]
1162 fn parts_are_counted_as_android_counts_them() {
1163 assert_eq!(parts(""), (0, true));
1164 assert_eq!(parts("Bonjour, à demain !"), (1, true));
1165 assert_eq!(parts(&"a".repeat(160)), (1, true));
1166 assert_eq!(parts(&"a".repeat(161)), (2, true));
1167 assert_eq!(parts(&"€".repeat(80)), (1, true), "two septets each: 160");
1168 assert_eq!(parts(&"€".repeat(81)), (2, true));
1169 assert_eq!(parts("Ça va ? 🙂"), (1, false), "an emoji: UCS-2");
1170 assert_eq!(parts(&"ç".repeat(71)), (2, false), "ç is no GSM letter (Ç is)");
1171 }
1172
1173 #[test]
1175 fn a_thread_says_each_message_s_day_and_time() {
1176 let tr = Translator::new("en");
1177 let now = paris(NOW);
1178 let names = |_: &str| None;
1179 let here = |_: &str| false;
1180 let v = viewer(&now, &tr, &names, &here);
1181 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() };
1182 let texts = [row("sms-1", NOW - 400 * 86_400_000), row("sms-2", NOW - 86_400_000), row("sms-3", NOW - MINUTE)];
1183 let said: Vec<(String, String)> = messages("+33199001234", &texts, &[], &[], &v).into_iter().map(|m| (m.day, m.time)).collect();
1184 assert_eq!(said[1], ("Yesterday".to_string(), "10:00".to_string()));
1185 assert_eq!(said[2], ("Today".to_string(), "09:59".to_string()));
1186 assert!(said[0].0.ends_with(" 2025") && said[0].0.contains("September"), "another year said: {:?}", said[0]);
1187 }
1188
1189 #[test]
1190 fn the_archive_is_searched_by_words_names_and_numbers_newest_first() {
1191 let tr = Translator::new("en");
1192 let now = paris(NOW);
1193 let names = |k: &str| (k == "+33199001234").then(|| "Pharmacie Sainte-Hélène".to_string());
1194 let here = |_: &str| false;
1195 let v = viewer(&now, &tr, &names, &here);
1196 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() };
1197 let texts = [
1198 row("sms-1", NOW - 3 * MINUTE, "+33199001234", "Votre ordonnance est prête."),
1199 row("sms-2", NOW - 2 * MINUTE, "+33465710042", "Le colis est arrivé au relais."),
1200 row("sms-3", NOW - MINUTE, "+33199001234", "Ordonnance renouvelée, à retirer demain."),
1201 ];
1202 let ids = |query: &str, limit: usize| search(query, &texts, &[], &[], &v, limit).into_iter().map(|f| f.message.id).collect::<Vec<_>>();
1203 assert_eq!(ids("ORDONNANCE", 10), ["phone/sms-3", "phone/sms-1"], "case aside, newest first");
1204 assert_eq!(ids("arrive", 10), ["phone/sms-2"], "accents aside");
1205 assert_eq!(ids("helene pret", 10), ["phone/sms-1"], "a name and a word, both");
1206 assert_eq!(ids("04 65 71", 10), ["phone/sms-2"], "a number as written");
1207 assert_eq!(ids("ordonnance", 1), ["phone/sms-3"]);
1208 assert!(ids(" ", 10).is_empty());
1209 let found = &search("colis", &texts, &[], &[], &v, 10)[0];
1210 assert_eq!((found.conversation.as_str(), found.title.as_str()), ("+33465710042", "04 65 71 00 42"));
1211 }
1212
1213 fn viewer<'a>(now: &'a Zoned, tr: &'a Translator, names: &'a dyn Fn(&str) -> Option<String>, here: &'a dyn Fn(&str) -> bool) -> Viewer<'a> {
1214 Viewer { now, tr, region: fr(), name_of: names, phone_shared: Some(NOW - 2 * MINUTE), media_here: here }
1215 }
1216
1217 fn paris(ms: i64) -> Zoned {
1218 jiff::Timestamp::from_millisecond(ms).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap())
1219 }
1220
1221 #[test]
1222 fn the_page_shows_conversations_and_each_request_s_state_in_words() {
1223 let tr = Translator::new("en");
1224 let now = paris(NOW);
1225 let names = |k: &str| (k == "+33199001234").then(|| "Dr Martin".to_string());
1226 let here = |_: &str| true;
1227 let v = viewer(&now, &tr, &names, &here);
1228 let texts = vec![
1229 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() },
1230 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() },
1231 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() },
1232 ];
1233 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) }];
1234 let outcomes = vec![
1235 Outcome { key: "k1".into(), state: "handed".into(), at: NOW - 5 * MINUTE, ..Outcome::default() },
1236 Outcome { key: "k1".into(), state: "sent".into(), at: NOW - 5 * MINUTE, row: "sms-9".into(), ..Outcome::default() },
1237 Outcome { key: "k3".into(), state: "expired".into(), at: NOW - 2 * MINUTE, ..Outcome::default() },
1238 ];
1239 let list = conversations(&texts, &requests, &v);
1240 assert_eq!(list.iter().map(|c| c.title.as_str()).collect::<Vec<_>>(), ["Dr Martin", "04 65 71 00 42"]);
1241 let shown = messages("+33199001234", &texts, &requests, &outcomes, &v);
1242 assert_eq!(shown.iter().map(|m| (m.direction.as_str(), m.key.as_str())).collect::<Vec<_>>(), [("in", ""), ("out", "k1"), ("out", "k2")]);
1243 assert_eq!(shown[1].said.as_ref().unwrap().text, "Sent at 09:55.");
1244 assert_eq!(shown[2].said.as_ref().unwrap().text, "Waiting for your phone (it last shared at 09:58).");
1245 let other = messages("+33465710042", &texts, &requests, &outcomes, &v);
1246 let expired = other.iter().find(|m| m.key == "k3").unwrap().said.clone().unwrap();
1247 assert!(expired.again && expired.state == "expired", "{expired:?}");
1248 assert_eq!(last_sim("+33199001234", "phone", &texts), 2);
1249 }
1250
1251 #[test]
1252 fn a_message_s_media_are_said_with_their_sizes_or_why_they_are_not_here() {
1253 let tr = Translator::new("en");
1254 let now = paris(NOW);
1255 let names = |_: &str| None;
1256 let here = |hash: &str| hash == "aa";
1257 let v = viewer(&now, &tr, &names, &here);
1258 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() };
1259 let mms = Text {
1260 id: "mms-31".into(),
1261 at: NOW - MINUTE,
1262 direction: "in".into(),
1263 with: vec!["+33465710042".into(), "+33199001234".into()],
1264 from: "+33465710042".into(),
1265 body: "Le colis.".into(),
1266 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")],
1267 device: "phone".into(),
1268 ..Text::default()
1269 };
1270 let shown = messages(&conversation_id(&mms.with), std::slice::from_ref(&mms), &[], &[], &v);
1271 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();
1272 assert_eq!(said.len(), 4, "the SMIL and the words are not shown apart: {said:?}");
1273 assert_eq!(said[0].0, "picture");
1274 assert!(said[0].2, "a picture here opens");
1275 assert!(said[1].1.contains("on your phone") && !said[1].2, "{said:?}");
1276 assert!(said[2].1.contains("its way") && !said[2].2, "its file not come yet: {said:?}");
1277 assert!(said[3].1.contains("not downloaded"), "{said:?}");
1278 assert_eq!(shown[0].from, "04 65 71 00 42", "a group says who wrote");
1279 assert_eq!(kept(std::slice::from_ref(&mms), &here), (1, 220_000), "only what is here is counted");
1280 }
1281
1282 #[test]
1283 fn delivered_is_said_only_when_the_carrier_says_so() {
1284 let tr = Translator::new("en");
1285 let now = paris(NOW);
1286 let sent = [Outcome { key: "h1".into(), state: "sent".into(), at: NOW + MINUTE, ..Outcome::default() }];
1287 assert_eq!(said(state_of("h1", &sent), None, &tr, &now).state, "sent", "no report: sent, never 'not delivered'");
1288 let delivered = [sent[0].clone(), Outcome { key: "h1".into(), state: "delivered".into(), at: NOW + 2 * MINUTE, ..Outcome::default() }];
1289 assert_eq!(said(state_of("h1", &delivered), None, &tr, &now).text, "Delivered at 10:02.");
1290 }
1291}