1use crate::appnotes::{self, Kind, Posted};
48use crate::calls;
49use crate::phonemsgs::{self, Extra};
50use crate::phones::Region;
51use crate::texts::Sealer;
52use serde::{Deserialize, Serialize};
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::{Path, PathBuf};
55
56pub const FOLDER: &str = "replies";
58pub const DUE: &str = "due.jsonl";
60pub const DONE: &str = "done.jsonl";
62pub const LIVE: &str = "live";
65pub const TAKE_WITHIN_MS: i64 = 2 * 60_000;
68pub const SHOWN_WITHIN_MS: i64 = 2 * 60_000;
71pub const WORDS_MAX: usize = 2000;
73const DAY_MS: i64 = 86_400_000;
75
76pub fn private_folder() -> PathBuf {
78 crate::config::state_dir().join(FOLDER)
79}
80
81pub fn notice(package: &str, key: &str) -> String {
85 appnotes::talk_key(package, key)
86}
87
88fn is_false(value: &bool) -> bool {
89 !*value
90}
91
92fn is_zero(value: &i64) -> bool {
93 *value == 0
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(default)]
103pub struct Due {
104 pub key: String,
106 pub verb: String,
108 pub notice: String,
110 pub app: String,
112 pub talk: String,
114 pub from: String,
116 pub name: String,
117 #[serde(skip_serializing_if = "is_false")]
120 pub group: bool,
121 #[serde(skip_serializing_if = "String::is_empty")]
122 pub title: String,
123 pub line_at: i64,
126 pub words: String,
128 pub at: i64,
130}
131
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct Done {
136 pub key: String,
137 pub state: String,
147 pub at: i64,
149 #[serde(skip_serializing_if = "is_false")]
151 pub crossed: bool,
152 #[serde(skip_serializing_if = "is_false")]
154 pub held: bool,
155}
156
157impl Done {
158 pub fn of(key: &str, state: &str, at: i64) -> Done {
159 Done { key: key.to_string(), state: state.to_string(), at, ..Done::default() }
160 }
161}
162
163pub fn read_due(folder: &Path, seal: &dyn Sealer) -> Vec<Due> {
165 crate::texts::read_file(&folder.join(DUE), seal)
166}
167
168pub fn read_done(folder: &Path) -> Vec<Done> {
170 calls::read_lines(&folder.join(DONE))
171}
172
173fn private_dir(folder: &Path) -> Result<(), String> {
175 let mut builder = std::fs::DirBuilder::new();
176 builder.recursive(true);
177 #[cfg(unix)]
178 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
179 builder.create(folder).map_err(|e| format!("{}: {e}", folder.display()))
180}
181
182pub fn open_ledger(folder: &Path, now_ms: i64) -> Result<crate::texts::Ledger, String> {
185 private_dir(folder)?;
186 crate::texts::Ledger::open(folder, now_ms)
187}
188
189pub fn mark_ledger(folder: &Path, now_ms: i64) -> Result<(), String> {
196 private_dir(folder)?;
197 if folder.join(crate::texts::MARK).is_file() {
198 return Ok(());
199 }
200 crate::filelock::with_lock(&folder.join(crate::texts::LEDGER), || crate::texts::Ledger::open(folder, now_ms).map(|_| ()))
201}
202
203pub fn write_due(folder: &Path, due: &[Due], seal: &dyn Sealer) -> Result<(), String> {
206 private_dir(folder)?;
207 let path = folder.join(DUE);
208 crate::filelock::with_lock(&path, || {
209 let body: String = due.iter().filter_map(|d| serde_json::to_string(d).ok()).map(|plain| format!("{}\n", seal.seal(&plain))).collect();
210 let fresh = folder.join(format!(".{DUE}.{}.new", std::process::id()));
211 let _ = std::fs::remove_file(&fresh);
212 calls::append_private(&fresh, &body)?;
213 std::fs::rename(&fresh, &path).map_err(|e| {
214 let _ = std::fs::remove_file(&fresh);
215 format!("{}: {e}", path.display())
216 })
217 })
218}
219
220fn append_flushed(path: &Path, done: &Done) -> Result<(), String> {
222 use std::io::Write;
223 let line = serde_json::to_string(done).map_err(|e| e.to_string())? + "\n";
224 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
225 calls::append_private(path, "")?;
226 let mut file = std::fs::OpenOptions::new().append(true).open(path).map_err(fail)?;
227 file.write_all(line.as_bytes()).map_err(fail)?;
228 file.sync_all().map_err(fail)
229}
230
231fn append_all_flushed(path: &Path, done: &[Done]) -> Result<(), String> {
233 use std::io::Write;
234 if done.is_empty() {
235 return Ok(());
236 }
237 let mut text = String::new();
238 for d in done {
239 text.push_str(&serde_json::to_string(d).map_err(|e| e.to_string())?);
240 text.push('\n');
241 }
242 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
243 calls::append_private(path, "")?;
244 let mut file = std::fs::OpenOptions::new().append(true).open(path).map_err(fail)?;
245 file.write_all(text.as_bytes()).map_err(fail)?;
246 file.sync_all().map_err(fail)
247}
248
249pub fn say(folder: &Path, done: &Done) -> Result<(), String> {
251 private_dir(folder)?;
252 crate::filelock::with_lock(&folder.join(DUE), || append_flushed(&folder.join(DONE), done))
253}
254
255pub fn waiting(folder: &Path, seal: &dyn Sealer) -> Vec<Due> {
257 let done = read_done(folder);
258 read_due(folder, seal).into_iter().filter(|d| !done.iter().any(|x| x.key == d.key)).collect()
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
263pub enum Taken {
264 Go(Box<Due>),
266 Late,
268 No,
270}
271
272pub fn take(folder: &Path, key: &str, seal: &dyn Sealer, now_ms: i64) -> Result<Taken, String> {
277 private_dir(folder)?;
278 crate::filelock::with_lock(&folder.join(DUE), || {
279 if read_done(folder).iter().any(|d| d.key == key) {
280 return Ok(Taken::No);
281 }
282 let Some(due) = read_due(folder, seal).into_iter().find(|d| d.key == key) else { return Ok(Taken::No) };
283 if now_ms - due.at > TAKE_WITHIN_MS || due.at - now_ms > TAKE_WITHIN_MS {
284 append_flushed(&folder.join(DONE), &Done::of(key, "late", now_ms))?;
285 return Ok(Taken::Late);
286 }
287 append_flushed(&folder.join(DONE), &Done::of(key, "taken", now_ms))?;
288 let rest: Vec<Due> = read_due(folder, seal).into_iter().map(|d| if d.key == key { Due { words: String::new(), ..d } } else { d }).collect();
291 let _ = write_due(folder, &rest, seal);
292 Ok(Taken::Go(Box::new(due)))
293 })
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum Checked {
301 Go { crossed: bool },
305 Gone,
307 Other,
312}
313
314pub fn check(due: &Due, posted: &Posted, extra: &Extra, choices: &appnotes::Choices, words: &crate::words::Words, region: Option<&Region>) -> Checked {
324 if posted.package != due.app || posted.key.is_empty() || notice(&posted.package, &posted.key) != due.notice {
325 return Checked::Gone;
326 }
327 let conversation = match appnotes::classify(posted, choices, words) {
328 Kind::People(talk) => talk.conversation,
329 _ => None,
330 };
331 let (group, title) = conversation.as_ref().map_or((false, String::new()), |c| (c.group, phonemsgs::title_of(c)));
332 if group != due.group || (due.group && title != due.title) {
333 return Checked::Other;
334 }
335 if due.group && conversation.as_ref().is_some_and(|c| c.by_writer) {
337 return Checked::Other;
338 }
339 let same = if !due.talk.is_empty() {
340 conversation.as_ref().is_some_and(|c| c.key == due.talk)
341 } else if !due.from.is_empty() {
342 phonemsgs::writers(posted, region).0.contains(&due.from)
343 } else if !due.name.is_empty() {
344 phonemsgs::writers(posted, region).1.contains(&due.name)
345 } else {
346 false
347 };
348 if !same {
349 return Checked::Other;
350 }
351 Checked::Go { crossed: phonemsgs::newest_timed_from_others(posted, extra) > due.line_at }
352}
353
354pub fn words_hash(words: &str) -> String {
356 appnotes::talk_key("replies", words.trim())
357}
358
359pub fn shows(posted: &Posted, extra: &Extra, hash: &str, since: i64, before: bool) -> bool {
369 let mut untimed = false;
370 for (i, m) in posted.messages.iter().enumerate() {
371 if m.sender.as_ref().is_some_and(|s| !phonemsgs::is_user(posted, s)) || words_hash(&m.text) != hash {
372 continue;
373 }
374 match extra.messages.get(i).map(|e| e.at).filter(|at| *at > 0) {
375 Some(at) if at >= since - 1_000 => return true,
376 Some(_) => {}
377 None => untimed = true,
378 }
379 }
380 !before && (untimed || extra.history.iter().any(|words| words_hash(words) == hash))
381}
382
383pub fn shown_already(posted: &Posted, extra: &Extra, hash: &str) -> bool {
386 let own = posted.messages.iter().filter(|m| m.sender.as_ref().is_none_or(|s| phonemsgs::is_user(posted, s))).map(|m| m.text.as_str());
387 own.chain(extra.history.iter().map(String::as_str)).any(|words| words_hash(words) == hash)
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(default)]
397pub struct Live {
398 pub notice: String,
400 pub app: String,
402 #[serde(skip_serializing_if = "String::is_empty")]
404 pub talk: String,
405 #[serde(skip_serializing_if = "is_false")]
406 pub reply: bool,
407 #[serde(skip_serializing_if = "is_false")]
408 pub read: bool,
409 #[serde(skip_serializing_if = "is_zero")]
411 pub gone: i64,
412 pub at: i64,
414 #[serde(skip)]
416 pub device: String,
417}
418
419pub fn live_file(root: &Path, device: &str) -> PathBuf {
421 calls::own_file(root, LIVE, device)
422}
423
424fn last_of(path: &Path) -> BTreeMap<String, Live> {
426 calls::read_lines::<Live>(path).into_iter().filter(|l| !l.notice.is_empty() && l.at > 0).map(|l| (l.notice.clone(), l)).collect()
427}
428
429pub fn offer(own: &Path, live: &Live) -> Result<bool, String> {
433 if live.notice.is_empty() || live.at <= 0 {
434 return Ok(false);
435 }
436 crate::filelock::with_lock(own, || {
437 let same = last_of(own).get(&live.notice).is_some_and(|last| last.gone == 0 && last.reply == live.reply && last.read == live.read && last.talk == live.talk && last.app == live.app);
438 if same {
439 return Ok(false);
440 }
441 let line = serde_json::to_string(&Live { gone: 0, device: String::new(), ..live.clone() }).map_err(|e| e.to_string())? + "\n";
442 calls::append_private(own, &line).map(|()| true)
443 })
444}
445
446pub fn went(own: &Path, notice: &str, now_ms: i64) -> Result<bool, String> {
450 crate::filelock::with_lock(own, || {
451 let Some(last) = last_of(own).remove(notice).filter(|l| l.gone == 0) else { return Ok(false) };
452 let line = serde_json::to_string(&Live { reply: false, read: false, gone: now_ms, at: now_ms, ..last }).map_err(|e| e.to_string())? + "\n";
453 calls::append_private(own, &line).map(|()| true)
454 })
455}
456
457pub fn read_live(root: &Path, now_ms: i64) -> BTreeMap<(String, String), Live> {
460 let mut out = BTreeMap::new();
461 for (device, path) in calls::device_files(&root.join(LIVE)) {
462 for (notice, live) in last_of(&path) {
463 if now_ms - live.at <= phonemsgs::KEPT_DAYS * DAY_MS {
464 out.insert((device.clone(), notice), Live { device: device.clone(), ..live });
465 }
466 }
467 }
468 out
469}
470
471pub const ASK: &str = "ask";
476pub const OUTCOME: &str = "outcome";
479pub const SAME_WORDS_MS: i64 = 10 * 60_000;
483pub const NOT_SENT: &[&str] = &["gone", "other", "no-reply", "no-read", "closed", "locked", "activity", "late", "failed"];
486pub const STEP_MAX: usize = 100;
491
492pub fn in_turn<'a>(asks: &'a [Ask], here: &str) -> Vec<&'a Ask> {
495 let mut by: BTreeMap<&str, Vec<&Ask>> = BTreeMap::new();
496 for ask in asks.iter().filter(|a| a.phone == here) {
497 by.entry(ask.device.as_str()).or_default().push(ask);
498 }
499 let longest = by.values().map(Vec::len).max().unwrap_or(0);
500 (0..longest).flat_map(|i| by.values().filter_map(move |list| list.get(i).copied())).collect()
501}
502pub const PER_HOUR: usize = 60;
504pub const PAD_STEP: usize = 256;
506const HOUR_MS: i64 = 3_600_000;
508
509#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(default)]
515pub struct Ask {
516 pub key: String,
519 pub phone: String,
521 pub verb: String,
523 pub notice: String,
525 pub app: String,
527 pub line: String,
529 #[serde(skip_serializing_if = "String::is_empty")]
531 pub words: String,
532 pub written: i64,
535 #[serde(skip_serializing_if = "is_zero")]
536 pub ahead: i64,
537 #[serde(skip_serializing_if = "String::is_empty")]
539 pub again: String,
540 pub writer: String,
546 #[serde(skip_serializing_if = "String::is_empty")]
548 pub pad: String,
549 #[serde(skip)]
551 pub device: String,
552}
553
554pub fn padded_ask(ask: &Ask) -> String {
557 let bare = Ask { pad: String::new(), device: String::new(), ..ask.clone() };
558 let text = serde_json::to_string(&bare).unwrap_or_default();
559 if text.len() % PAD_STEP == 0 {
560 return text;
561 }
562 let room = text.len() + 9;
564 let spaces = (PAD_STEP - room % PAD_STEP) % PAD_STEP;
565 serde_json::to_string(&Ask { pad: " ".repeat(spaces), ..bare }).unwrap_or(text)
566}
567
568pub fn write_ask(root: &Path, device: &str, ask: &Ask, seal: &dyn Sealer) -> Result<bool, String> {
572 let own = calls::own_file(root, ASK, device);
573 let ask = Ask { writer: device.to_string(), ..ask.clone() };
574 crate::filelock::with_lock(&own, || {
575 if crate::texts::read_file::<Ask>(&own, seal).iter().any(|a| a.key == ask.key && a.verb == ask.verb) {
576 return Ok(false);
577 }
578 calls::append_private(&own, &format!("{}\n", seal.seal(&padded_ask(&ask)))).map(|()| true)
579 })
580}
581
582pub fn read_asks(roots: &[PathBuf], seal: &dyn Sealer) -> (Vec<Ask>, BTreeSet<(String, String)>) {
590 let mut asks: Vec<Ask> = Vec::new();
591 let mut known: BTreeSet<String> = BTreeSet::new();
592 let mut cancelled = BTreeSet::new();
593 for root in roots {
594 for (device, path) in calls::device_files(&root.join(ASK)) {
595 for ask in crate::texts::read_file::<Ask>(&path, seal) {
596 if ask.key.is_empty() || ask.writer.is_empty() || calls::file_safe(&ask.writer) != device {
597 continue;
598 }
599 let ask = Ask { device: ask.writer.clone(), ..ask };
600 if ask.verb == "cancel" {
601 cancelled.insert((ask.device.clone(), ask.key.clone()));
602 } else if known.insert(ask.key.clone()) {
603 asks.push(ask);
604 }
605 }
606 }
607 }
608 (asks, cancelled)
609}
610
611pub const READ_MARKS: &str = "asks-read.json";
619
620#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
624pub struct ReadMark {
625 pub offset: u64,
626 pub line: String,
627}
628
629#[derive(Debug, Clone, Default, PartialEq, Eq)]
633pub struct FileRead {
634 pub path: String,
635 pub start: u64,
636 pub lines: Vec<(u64, String, String, String)>,
637}
638
639pub fn read_marks(folder: &Path) -> BTreeMap<String, ReadMark> {
641 std::fs::read_to_string(folder.join(READ_MARKS)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
642}
643
644pub fn save_read_marks(folder: &Path, marks: &BTreeMap<String, ReadMark>) -> Result<(), String> {
646 private_dir(folder)?;
647 let text = serde_json::to_string(marks).map_err(|e| e.to_string())?;
648 let fresh = folder.join(format!(".{READ_MARKS}.{}.new", std::process::id()));
649 let _ = std::fs::remove_file(&fresh);
650 calls::append_private(&fresh, &text)?;
651 std::fs::rename(&fresh, folder.join(READ_MARKS)).map_err(|e| format!("{}: {e}", folder.display()))
652}
653
654fn line_hash(line: &str) -> String {
656 appnotes::talk_key("asks", line)
657}
658
659fn holds(bytes: &[u8], mark: &ReadMark) -> bool {
661 let end = usize::try_from(mark.offset).unwrap_or(usize::MAX);
662 if end == 0 || end > bytes.len() || bytes[end - 1] != b'\n' {
663 return false;
664 }
665 let before = &bytes[..end - 1];
666 let start = before.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
667 line_hash(String::from_utf8_lossy(&before[start..]).trim()) == mark.line
668}
669
670pub fn read_asks_after(roots: &[PathBuf], seal: &dyn Sealer, marks: &BTreeMap<String, ReadMark>) -> ((Vec<Ask>, BTreeSet<(String, String)>), Vec<FileRead>) {
676 let mut asks: Vec<Ask> = Vec::new();
677 let mut known: BTreeSet<String> = BTreeSet::new();
678 let mut cancelled = BTreeSet::new();
679 let mut reads = Vec::new();
680 for root in roots {
681 for (device, path) in calls::device_files(&root.join(ASK)) {
682 let Ok(bytes) = std::fs::read(&path) else { continue };
683 let name = path.display().to_string();
684 let start = marks.get(&name).filter(|m| holds(&bytes, m)).map_or(0, |m| usize::try_from(m.offset).unwrap_or(0));
685 let mut read = FileRead { path: name, start: start as u64, lines: Vec::new() };
686 let mut at = start;
687 for piece in bytes[start..].split_inclusive(|b| *b == b'\n') {
688 if !piece.ends_with(b"\n") {
689 break;
690 }
691 at += piece.len();
692 let text = String::from_utf8_lossy(piece);
693 let line = text.trim();
694 let opened = if line.is_empty() { None } else { seal.open(line).and_then(|plain| serde_json::from_str::<Ask>(&plain).ok()) };
695 let (key, phone) = match opened {
696 Some(ask) if !ask.key.is_empty() && !ask.writer.is_empty() && calls::file_safe(&ask.writer) == device => {
697 let ask = Ask { device: ask.writer.clone(), ..ask };
698 let named = (ask.key.clone(), ask.phone.clone());
699 if ask.verb == "cancel" {
700 cancelled.insert((ask.device.clone(), ask.key.clone()));
701 } else if known.insert(ask.key.clone()) {
702 asks.push(ask);
703 }
704 named
705 }
706 _ => (String::new(), String::new()),
707 };
708 read.lines.push((at as u64, line_hash(line), key, phone));
709 }
710 reads.push(read);
711 }
712 }
713 ((asks, cancelled), reads)
714}
715
716pub fn moved_marks(reads: &[FileRead], done: &dyn Fn(&str, &str) -> bool, marks: &BTreeMap<String, ReadMark>) -> BTreeMap<String, ReadMark> {
722 let mut out = BTreeMap::new();
723 for read in reads {
724 let mut mark = marks.get(&read.path).filter(|m| m.offset == read.start && read.start > 0).cloned().unwrap_or_default();
725 for (end, hash, key, phone) in &read.lines {
726 if !key.is_empty() && !done(key, phone) {
727 break;
728 }
729 mark = ReadMark { offset: *end, line: hash.clone() };
730 }
731 if mark.offset > 0 {
732 out.insert(read.path.clone(), mark);
733 }
734 }
735 out
736}
737
738#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(default)]
741pub struct Outcome {
742 pub key: String,
743 pub state: String,
747 pub at: i64,
749 #[serde(skip_serializing_if = "String::is_empty")]
750 pub reason: String,
751 #[serde(skip_serializing_if = "is_false")]
752 pub crossed: bool,
753 #[serde(skip_serializing_if = "is_false")]
754 pub held: bool,
755 #[serde(skip)]
757 pub device: String,
758}
759
760pub fn read_outcomes(root: &Path) -> Vec<Outcome> {
762 let mut all: Vec<Outcome> = calls::device_files(&root.join(OUTCOME)).into_iter().flat_map(|(device, path)| calls::read_lines::<Outcome>(&path).into_iter().map(move |o| Outcome { device: device.clone(), ..o })).collect();
763 all.sort_by_key(|o| o.at);
764 all
765}
766
767pub fn say_outcomes(own: &Path, outcomes: &[Outcome]) -> Result<usize, String> {
770 if outcomes.is_empty() {
771 return Ok(0);
772 }
773 crate::filelock::with_lock(own, || {
774 let mut known: BTreeSet<(String, String)> = calls::read_lines::<Outcome>(own).into_iter().map(|o| (o.key, o.state)).collect();
775 let mut text = String::new();
776 for o in outcomes {
777 if known.insert((o.key.clone(), o.state.clone())) {
778 text.push_str(&serde_json::to_string(&Outcome { device: String::new(), ..o.clone() }).map_err(|e| e.to_string())?);
779 text.push('\n');
780 }
781 }
782 let written = text.lines().count();
783 if written > 0 {
784 calls::append_private(own, &text)?;
785 }
786 Ok(written)
787 })
788}
789
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
792pub enum Verdict {
793 Send,
795 NotMine,
797 Decided,
799 TakenBack,
801 Expired,
803 Clocks,
805 Refused(&'static str),
810 Wait,
814}
815
816pub struct Judge<'a> {
818 pub here: &'a str,
820 pub now_ms: i64,
821 pub ledger: &'a crate::texts::Ledger,
823 pub lines: &'a [phonemsgs::Line],
825 pub standing: &'a dyn Fn(&str) -> crate::texts::Standing,
830 pub cancelled: &'a BTreeSet<(String, String)>,
832 pub answers: &'a dyn Fn(&str) -> bool,
834 pub unsent: &'a BTreeSet<String>,
836 pub went: &'a BTreeSet<String>,
838}
839
840pub fn went_keys(folder: &Path) -> BTreeSet<String> {
842 read_done(folder).into_iter().filter(|d| matches!(d.state.as_str(), "handed" | "shown")).map(|d| d.key).collect()
843}
844
845pub fn unsent(folder: &Path) -> BTreeSet<String> {
848 read_done(folder).into_iter().filter(|d| NOT_SENT.contains(&d.state.as_str())).map(|d| d.key).collect()
849}
850
851pub fn verdict(ask: &Ask, j: &Judge) -> Verdict {
864 if ask.phone != j.here || ask.key.is_empty() || ask.verb == "cancel" {
865 return Verdict::NotMine;
866 }
867 if j.ledger.has(&ask.key) {
868 return Verdict::Decided;
869 }
870 if j.cancelled.contains(&(ask.device.clone(), ask.key.clone())) {
871 return Verdict::TakenBack;
872 }
873 match (j.standing)(&ask.device) {
874 crate::texts::Standing::Counted => {}
875 crate::texts::Standing::Gone => return Verdict::Refused("not-counted"),
876 crate::texts::Standing::Unknown => {
877 let written = ask.written - ask.ahead;
878 return if written > j.now_ms + crate::texts::AHEAD_MS {
879 Verdict::Clocks
880 } else if j.now_ms - written > crate::texts::EXPIRY_MS {
881 Verdict::Expired
882 } else {
883 Verdict::Wait
884 };
885 }
886 }
887 let reply = ask.verb == "reply";
888 if !reply && ask.verb != "read" {
889 return Verdict::Refused("verb");
890 }
891 if reply && ask.words.trim().is_empty() {
892 return Verdict::Refused("no-words");
893 }
894 if reply && ask.words.chars().count() > WORDS_MAX {
895 return Verdict::Refused("too-long");
896 }
897 let Some(line) = j.lines.iter().find(|l| l.id == ask.line && !l.notice.is_empty()) else { return Verdict::Refused("line") };
898 if line.notice != ask.notice || line.app != ask.app {
899 return Verdict::Refused("line");
900 }
901 if line.code {
902 return Verdict::Refused("code");
903 }
904 if !(j.answers)(&ask.app) {
905 return Verdict::Refused("switched-off");
906 }
907 if line.kind == "text" && !line.group && (line.who == "automaton" || !crate::phones::is_whole(&line.key)) {
908 return Verdict::Refused("short-number");
909 }
910 let written = ask.written - ask.ahead;
911 if written < j.ledger.mark.since {
912 return Verdict::Refused("before-mark");
913 }
914 if written > j.now_ms + crate::texts::AHEAD_MS {
915 return Verdict::Clocks;
916 }
917 if j.now_ms - written > crate::texts::EXPIRY_MS {
918 return Verdict::Expired;
919 }
920 let lately = j.ledger.claims.iter().filter(|c| c.state == "claimed" && j.now_ms - c.at <= HOUR_MS);
922 if reply {
923 let hash = words_hash(&ask.words);
924 let replies: Vec<&crate::texts::Claim> = lately.filter(|c| !c.hash.is_empty()).collect();
925 let same = |c: &crate::texts::Claim| c.to == ask.notice || (!c.talk.is_empty() && c.talk == line.talk);
929 let twin = replies.iter().find(|c| !j.unsent.contains(&c.key) && same(c) && c.hash == hash && j.now_ms - c.at <= SAME_WORDS_MS && c.key != ask.again);
930 if let Some(twin) = twin {
931 return Verdict::Refused(if j.went.contains(&twin.key) { "same-words" } else { "same-words-pending" });
933 }
934 if replies.len() >= PER_HOUR {
935 return Verdict::Refused("too-many");
936 }
937 } else if lately.filter(|c| c.hash.is_empty()).count() >= PER_HOUR {
938 return Verdict::Refused("too-many-read");
939 }
940 Verdict::Send
941}
942
943pub fn claim_of(ask: &Ask, talk: &str, now_ms: i64) -> crate::texts::Claim {
947 crate::texts::Claim { key: ask.key.clone(), at: now_ms, state: "claimed".into(), to: ask.notice.clone(), hash: if ask.verb == "reply" { words_hash(&ask.words) } else { String::new() }, talk: talk.to_string(), ..crate::texts::Claim::default() }
948}
949
950pub fn end_of(key: &str, state: &str, reason: &str, now_ms: i64) -> crate::texts::Claim {
955 let state = if reason.is_empty() { state.to_string() } else { format!("{state}:{reason}") };
956 crate::texts::Claim { key: key.to_string(), at: now_ms, state, ..crate::texts::Claim::default() }
957}
958
959pub fn ends_of(ledger: &crate::texts::Ledger, now_ms: i64) -> Vec<Outcome> {
963 ledger
964 .claims
965 .iter()
966 .filter(|c| now_ms - c.at <= DAY_MS)
967 .filter_map(|c| {
968 let (state, reason) = c.state.split_once(':').unwrap_or((c.state.as_str(), ""));
969 matches!(state, "taken-back" | "expired" | "clocks" | "refused").then(|| Outcome { key: c.key.clone(), state: state.to_string(), at: c.at, reason: reason.to_string(), ..Outcome::default() })
970 })
971 .collect()
972}
973
974pub fn due_of(ask: &Ask, line: &phonemsgs::Line, now_ms: i64) -> Due {
977 Due { key: ask.key.clone(), verb: ask.verb.clone(), notice: ask.notice.clone(), app: ask.app.clone(), talk: line.talk.clone(), from: line.key.clone(), name: line.name.clone(), group: line.group, title: line.title.clone(), line_at: line.at, words: if ask.verb == "reply" { ask.words.clone() } else { String::new() }, at: now_ms }
978}
979
980pub fn add_due(folder: &Path, fresh: &[Due], seal: &dyn Sealer, now_ms: i64) -> Result<(), String> {
983 private_dir(folder)?;
984 crate::filelock::with_lock(&folder.join(DUE), || {
985 let done = read_done(folder);
986 let mut due: Vec<Due> = read_due(folder, seal).into_iter().filter(|d| now_ms - d.at <= DAY_MS || !done.iter().any(|x| x.key == d.key)).filter(|d| !fresh.iter().any(|f| f.key == d.key)).collect();
987 due.extend(fresh.iter().cloned());
988 write_due(folder, &due, seal)
989 })
990}
991
992pub fn settle(folder: &Path, claimed: &[(String, i64)], now_ms: i64) -> Result<Vec<Done>, String> {
998 private_dir(folder)?;
999 crate::filelock::with_lock(&folder.join(DUE), || {
1000 let done = read_done(folder);
1001 let mut words: BTreeMap<&str, Vec<&Done>> = BTreeMap::new();
1002 for d in &done {
1003 words.entry(d.key.as_str()).or_default().push(d);
1004 }
1005 let mut ends = Vec::new();
1006 for (key, at) in claimed {
1007 let said = words.get(key.as_str()).map_or(&[][..], Vec::as_slice);
1008 if said.is_empty() && now_ms - at > TAKE_WITHIN_MS {
1009 ends.push(Done::of(key, "late", now_ms));
1010 } else if said.len() == 1 && said[0].state == "taken" && now_ms - said[0].at > crate::texts::LOOK_AFTER_MS {
1011 ends.push(Done::of(key, "doubt", now_ms));
1012 }
1013 }
1014 append_all_flushed(&folder.join(DONE), &ends)?;
1016 Ok(read_done(folder))
1017 })
1018}
1019
1020pub fn is_final(state: &str) -> bool {
1023 !matches!(state, "taken" | "handed")
1024}
1025
1026pub fn outcomes_of(done: &[Done]) -> Vec<Outcome> {
1029 done.iter().map(|d| Outcome { key: d.key.clone(), state: d.state.clone(), at: d.at, crossed: d.crossed, held: d.held, ..Outcome::default() }).collect()
1030}
1031
1032pub fn trim_private(folder: &Path, seal: &dyn Sealer, now_ms: i64) -> Result<(), String> {
1035 crate::filelock::with_lock(&folder.join(DUE), || {
1036 let due: Vec<Due> = read_due(folder, seal).into_iter().filter(|d| now_ms - d.at <= DAY_MS).collect();
1037 if folder.join(DUE).is_file() {
1038 write_due(folder, &due, seal)?;
1039 }
1040 calls::rewrite_lines(&folder.join(DONE), |line| serde_json::from_str::<Done>(line).is_ok_and(|d| now_ms - d.at > DAY_MS)).map(|_| ())
1041 })?;
1042 crate::texts::trim_ledger(folder, now_ms).map(|_| ())
1043}
1044
1045pub fn trim_shared(root: &Path, here: &str, seal: &dyn Sealer, now_ms: i64) -> Result<usize, String> {
1051 let ask_old = |days: i64| move |line: &str| seal.open(line.trim()).and_then(|plain| serde_json::from_str::<Ask>(&plain).ok()).is_some_and(|a| now_ms - a.written > days * DAY_MS);
1052 let outcome_old = |days: i64| move |line: &str| serde_json::from_str::<Outcome>(line).is_ok_and(|o| now_ms - o.at > days * DAY_MS);
1053 let mut gone = 0;
1054 for (device, path) in calls::device_files(&root.join(ASK)) {
1055 let days = if device == calls::file_safe(here) { phonemsgs::KEPT_DAYS } else { phonemsgs::KEPT_DAYS + phonemsgs::LATE_DAYS };
1056 gone += calls::rewrite_lines(&path, ask_old(days))?;
1057 }
1058 for (device, path) in calls::device_files(&root.join(OUTCOME)) {
1059 let days = if device == calls::file_safe(here) { phonemsgs::KEPT_DAYS } else { phonemsgs::KEPT_DAYS + phonemsgs::LATE_DAYS };
1060 gone += calls::rewrite_lines(&path, outcome_old(days))?;
1061 }
1062 Ok(gone)
1063}
1064
1065#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1070pub struct State {
1071 pub said: String,
1073 pub waiting: bool,
1075 pub again: bool,
1077 pub on_phone: bool,
1079 pub state: String,
1081}
1082
1083fn rank(state: &str) -> u8 {
1085 match state {
1086 "shown" => 4,
1087 "handed" => 3,
1088 "taken" => 1,
1089 _ => 2,
1090 }
1091}
1092
1093pub fn state_of(ask: &Ask, outcomes: &[&Outcome], app: &str, tr: &crate::i18n::Translator, phone_shared: Option<i64>, cancel: bool, time_of: &dyn Fn(i64) -> String) -> State {
1100 let mut args = crate::i18n::args();
1101 args.set("app", app.to_string());
1102 let Some(last) = outcomes.iter().copied().max_by_key(|o| (rank(&o.state), o.at)) else {
1103 if cancel {
1104 return State { said: tr.text("replies-taking-back", None), ..State::default() };
1106 }
1107 return match phone_shared {
1108 Some(at) => {
1109 args.set("time", time_of(at));
1110 State { said: tr.text("replies-waiting", Some(&args)), waiting: true, ..State::default() }
1111 }
1112 None => State { said: tr.text("replies-waiting-never", None), waiting: true, ..State::default() },
1113 };
1114 };
1115 args.set("time", time_of(last.at));
1116 let read = ask.verb == "read";
1117 let (id, again, on_phone) = match (last.state.as_str(), read) {
1118 ("handed", true) => ("replies-read-handed", false, false),
1119 ("handed", false) if last.held && last.crossed => ("replies-handed-held", false, false),
1120 ("handed", false) if last.crossed => ("replies-handed-crossed", false, false),
1121 ("handed", false) => ("replies-handed", false, false),
1122 ("shown", _) => ("replies-shown", false, false),
1123 ("taken", _) => ("replies-taken", false, false),
1124 ("gone", _) => ("replies-gone", false, true),
1125 ("other", _) => ("replies-other", false, true),
1126 ("no-reply", _) => ("replies-no-reply", false, true),
1127 ("no-read", _) => ("replies-no-read", false, true),
1128 ("closed", _) => ("replies-closed", false, true),
1129 ("locked", _) => ("replies-locked", false, true),
1130 ("activity", _) => ("replies-activity", false, true),
1131 ("late", _) => ("replies-late", true, false),
1132 ("failed", _) => ("replies-failed", true, false),
1133 ("expired", _) => ("replies-expired", true, false),
1134 ("clocks", _) => ("replies-clocks", true, false),
1135 ("doubt", _) => ("replies-doubt", true, false),
1136 ("taken-back", _) => ("replies-taken-back", false, false),
1137 ("refused", _) => match last.reason.as_str() {
1138 "same-words" => ("replies-refused-same-words", false, false),
1139 "same-words-pending" => ("replies-refused-same-words-pending", true, false),
1141 "too-many" => ("replies-refused-too-many", false, false),
1142 "too-many-read" => ("replies-refused-too-many-read", false, false),
1143 "too-long" => ("replies-refused-too-long", false, false),
1144 "no-words" => ("replies-refused-no-words", false, false),
1145 "line" => ("replies-refused-line", false, true),
1146 "code" => ("replies-refused-code", false, true),
1147 "switched-off" => ("replies-refused-switched-off", false, true),
1148 "short-number" => ("replies-refused-short-number", false, true),
1149 "not-counted" => ("replies-refused-not-counted", false, false),
1150 "before-mark" => ("replies-refused-before-mark", true, false),
1151 _ => ("replies-refused", false, true),
1152 },
1153 _ => ("replies-refused", false, true),
1154 };
1155 let mut said = tr.text(id, Some(&args));
1156 if cancel && matches!(last.state.as_str(), "taken" | "handed" | "shown" | "doubt") {
1158 said = format!("{said} {}", tr.text("replies-take-back-late", None));
1159 }
1160 State { said, again, on_phone, state: last.state.clone(), ..State::default() }
1161}
1162
1163pub const ANSWERED: &str = "answered.json";
1169pub const ANSWERED_MS: i64 = 20 * 60_000;
1173
1174fn answered(folder: &Path, now_ms: i64) -> BTreeMap<String, i64> {
1176 std::fs::read_to_string(folder.join(ANSWERED)).ok().and_then(|t| serde_json::from_str::<BTreeMap<String, i64>>(&t).ok()).unwrap_or_default().into_iter().filter(|(_, until)| *until > now_ms).collect()
1177}
1178
1179pub fn note_answered(folder: &Path, talk: &str, now_ms: i64) -> Result<(), String> {
1182 if talk.is_empty() {
1183 return Ok(());
1184 }
1185 private_dir(folder)?;
1186 crate::filelock::with_lock(&folder.join(ANSWERED), || {
1187 let mut kept = answered(folder, now_ms);
1188 kept.insert(talk.to_string(), now_ms + ANSWERED_MS);
1189 let text = serde_json::to_string(&kept).map_err(|e| e.to_string())?;
1190 let fresh = folder.join(format!(".{ANSWERED}.{}.new", std::process::id()));
1191 let _ = std::fs::remove_file(&fresh);
1192 calls::append_private(&fresh, &text)?;
1193 std::fs::rename(&fresh, folder.join(ANSWERED)).map_err(|e| format!("{}: {e}", folder.display()))
1194 })
1195}
1196
1197pub fn answered_lately(folder: &Path, talk: &str, now_ms: i64) -> bool {
1199 !talk.is_empty() && answered(folder, now_ms).contains_key(talk)
1200}
1201
1202pub fn answered_number(number: &str) -> String {
1205 if number.is_empty() { String::new() } else { format!("tel:{}", appnotes::talk_key("tel", number)) }
1206}
1207
1208#[derive(Debug, Clone, Default, PartialEq, Eq)]
1213pub struct Answered {
1214 talks: BTreeSet<(String, String)>,
1215 numbers: BTreeSet<String>,
1216}
1217
1218impl Answered {
1219 pub fn of(own: &[Ask], outcomes: &[Outcome], all: &[phonemsgs::Line], now_ms: i64) -> Answered {
1224 let lately: BTreeSet<(&str, &str)> = outcomes.iter().filter(|o| matches!(o.state.as_str(), "handed" | "shown") && (-crate::texts::AHEAD_MS..=ANSWERED_MS).contains(&(now_ms - o.at))).map(|o| (o.device.as_str(), o.key.as_str())).collect();
1225 let talks = own
1226 .iter()
1227 .filter(|a| a.verb == "reply" && lately.contains(&(a.phone.as_str(), a.key.as_str())))
1228 .filter_map(|a| all.iter().find(|l| l.device == a.phone && l.id == a.line))
1229 .filter(|l| !l.talk.is_empty())
1230 .map(|l| (l.device.clone(), l.talk.clone()))
1231 .collect();
1232 Answered { talks, numbers: BTreeSet::new() }
1233 }
1234
1235 pub fn texted(&mut self, number: String) {
1237 if !number.is_empty() {
1238 self.numbers.insert(number);
1239 }
1240 }
1241
1242 pub fn holds(&self, line: &phonemsgs::Line) -> bool {
1245 (!line.talk.is_empty() && self.talks.contains(&(line.device.clone(), line.talk.clone()))) || (line.kind == "text" && !line.key.is_empty() && self.numbers.contains(&line.key))
1246 }
1247}
1248
1249pub const MARKS_READ: &[&str] = &["org.thoughtcrime.securesms", "org.telegram.messenger", "foundation.e.message", "com.moez.QKSMS", "dev.octoshrimpy.quik"];
1255
1256#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1258pub struct Answer {
1259 pub phone: String,
1263 pub notice: String,
1264 pub app: String,
1265 pub label: String,
1266 pub line: String,
1267 pub offered: bool,
1269 pub read: bool,
1271 pub texts: bool,
1275 pub placeholder: String,
1277 pub note: String,
1279 pub read_item: String,
1281 pub read_warning: String,
1282 pub on_phone: String,
1284 pub limit: usize,
1286 pub mine: Vec<Mine>,
1288}
1289
1290#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1292pub struct Mine {
1293 pub key: String,
1294 pub time: String,
1296 pub text: String,
1297 pub said: String,
1299 pub waiting: bool,
1300 pub again: bool,
1301 pub texts: bool,
1303}
1304
1305pub struct Offer<'a> {
1307 pub live: &'a BTreeMap<(String, String), Live>,
1309 pub asks: &'a [Ask],
1311 pub outcomes: &'a [Outcome],
1313 pub tr: &'a crate::i18n::Translator,
1314 pub taken_back: &'a BTreeSet<String>,
1316 pub lines: &'a [phonemsgs::Line],
1319 pub texts_from: &'a dyn Fn(&str) -> bool,
1323 pub shared: &'a dyn Fn(&str) -> Option<i64>,
1325 pub time_of: &'a dyn Fn(i64) -> String,
1327 pub sent_texts: &'a dyn Fn(&str, &str, i64) -> Vec<Mine>,
1329}
1330
1331pub fn answer_of(line: &phonemsgs::PorchLine, group: &[&phonemsgs::Line], o: &Offer) -> Option<Answer> {
1335 let newest = group.last()?;
1336 if line.code || newest.code || newest.notice.is_empty() {
1337 return None;
1338 }
1339 let tr = o.tr;
1340 let label = if newest.label.is_empty() { newest.app.clone() } else { newest.label.clone() };
1341 let mut args = crate::i18n::args();
1342 args.set("app", label.clone());
1343 args.set("who", line.who.clone());
1344 let placeholder = tr.text(if newest.group { "replies-placeholder-group" } else { "replies-placeholder" }, Some(&args));
1345 let base = Answer {
1346 phone: newest.device.clone(),
1347 app: newest.app.clone(),
1348 label: label.clone(),
1349 line: newest.id.clone(),
1350 placeholder,
1351 read_item: tr.text("replies-read-item", Some(&args)),
1352 read_warning: tr.text("replies-read-warning", Some(&args)),
1353 on_phone: tr.text("replies-on-phone", None),
1354 ..Answer::default()
1355 };
1356 if line.texted && (o.texts_from)(&newest.device) {
1358 let since = group.first().map_or(newest.at, |l| l.at);
1359 return Some(Answer { notice: newest.notice.clone(), texts: true, offered: true, limit: crate::texts::WRITE_MAX, mine: (o.sent_texts)(&newest.device, &line.dial, since), ..base });
1360 }
1361 let said = o.live.get(&(newest.device.clone(), newest.notice.clone()));
1366 let current = said.filter(|l| l.gone == 0).cloned().or_else(|| {
1367 (!newest.talk.is_empty()).then(|| o.live.values().filter(|l| l.device == newest.device && l.talk == newest.talk && l.gone == 0 && l.reply).max_by_key(|l| l.at).cloned()).flatten()
1368 });
1369 let mut answered = newest.id.clone();
1370 let (notice, offered, read) = match current {
1371 Some(l) if l.notice == newest.notice => (l.notice.clone(), l.reply, l.read),
1372 Some(l) => match o.lines.iter().filter(|x| x.device == newest.device && x.notice == l.notice && x.talk == newest.talk && x.group == newest.group && x.title == newest.title && !x.code).max_by_key(|x| x.at) {
1377 Some(theirs) => {
1378 answered = theirs.id.clone();
1379 (l.notice.clone(), l.reply, l.read)
1380 }
1381 None => (newest.notice.clone(), false, false),
1382 },
1383 None => (newest.notice.clone(), false, false),
1384 };
1385 let service = newest.kind == "text" && !newest.group && (newest.who == "automaton" || !crate::phones::is_whole(&newest.key));
1388 let (offered, read) = if service { (false, false) } else { (offered, read) };
1389 let note = if MARKS_READ.contains(&newest.app.as_str()) { tr.text("replies-note-marks-read", Some(&args)) } else { String::new() };
1390 let ids: BTreeSet<&str> = group.iter().map(|l| l.id.as_str()).collect();
1391 let mut mine: Vec<&Ask> = o.asks.iter().filter(|a| a.verb == "reply" && a.phone == newest.device && (ids.contains(a.line.as_str()) || a.notice == notice)).collect();
1392 mine.sort_by_key(|a| a.written);
1393 let mine = mine
1394 .into_iter()
1395 .map(|a| {
1396 let theirs: Vec<&Outcome> = o.outcomes.iter().filter(|x| x.key == a.key && x.device == a.phone).collect();
1397 let state = state_of(a, &theirs, &label, tr, (o.shared)(&a.phone), o.taken_back.contains(&a.key), o.time_of);
1398 Mine { key: a.key.clone(), time: (o.time_of)(a.written), text: a.words.clone(), said: state.said, waiting: state.waiting, again: state.again, texts: false }
1399 })
1400 .collect();
1401 Some(Answer { notice, offered, read, note, limit: WORDS_MAX, mine, line: answered, ..base })
1402}
1403
1404pub fn read_handed(asks: &[Ask], outcomes: &[Outcome], phone: &str, notice: &str) -> bool {
1407 asks.iter().filter(|a| a.verb == "read" && a.phone == phone && a.notice == notice).any(|a| outcomes.iter().any(|o| o.key == a.key && o.device == phone && o.state == "handed"))
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412 use super::*;
1413 use crate::appnotes::{Message, Person};
1414 use crate::phonemsgs::ExtraMessage;
1415
1416 const NOW: i64 = 1_791_446_400_000;
1418 const MINUTE: i64 = 60_000;
1419
1420 struct Stand;
1422
1423 impl Sealer for Stand {
1424 fn seal(&self, plain: &str) -> String {
1425 format!("sealed:{}", plain.bytes().map(|b| format!("{:02x}", b ^ 0x5a)).collect::<String>())
1426 }
1427 fn open(&self, sealed: &str) -> Option<String> {
1428 let hex = sealed.strip_prefix("sealed:")?;
1429 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();
1430 String::from_utf8(bytes?).ok()
1431 }
1432 }
1433
1434 fn scratch(name: &str) -> PathBuf {
1435 let dir = std::env::temp_dir().join(format!("sioul-replies-{name}-{}", std::process::id()));
1436 let _ = std::fs::remove_dir_all(&dir);
1437 dir
1438 }
1439
1440 fn words() -> std::sync::Arc<crate::words::Words> {
1441 let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
1442 crate::words::Words::of(&config)
1443 }
1444
1445 fn fr() -> Option<&'static Region> {
1446 crate::phones::region_named("FR")
1447 }
1448
1449 fn signal(last: i64, mine: Option<&str>) -> (Posted, Extra) {
1452 let alice = Person { name: "Alice".into(), key: "alice".into(), uri: "tel:+33199001234".into(), ..Person::default() };
1453 let mut messages = vec![Message { sender: Some(alice.clone()), text: "Tu passes ?".into() }, Message { sender: Some(alice.clone()), text: "Je suis au café.".into() }];
1454 let mut times = vec![ExtraMessage { at: last - MINUTE, picture: false }, ExtraMessage { at: last, picture: false }];
1455 if let Some(mine) = mine {
1456 messages.push(Message { sender: None, text: mine.into() });
1457 times.push(ExtraMessage { at: last + MINUTE, picture: false });
1458 }
1459 let posted = Posted {
1460 key: "0|org.thoughtcrime.securesms|42|null|10123".into(),
1461 package: "org.thoughtcrime.securesms".into(),
1462 app: "Signal".into(),
1463 category: "msg".into(),
1464 template: "android.app.Notification$MessagingStyle".into(),
1465 title: "Alice".into(),
1466 shortcut: "alice-thread".into(),
1467 is_conversation: true,
1468 messages,
1469 people: vec![alice],
1470 ..Posted::default()
1471 };
1472 (posted, Extra { when: last, posted: last, messages: times, ..Extra::default() })
1473 }
1474
1475 fn due_for(posted: &Posted, key: &str, line_at: i64, at: i64) -> Due {
1476 let talk = match appnotes::classify(posted, &appnotes::Choices::default(), &words()) {
1477 Kind::People(talk) => talk.conversation.map(|c| c.key).unwrap_or_default(),
1478 _ => String::new(),
1479 };
1480 Due { key: key.into(), verb: "reply".into(), notice: notice(&posted.package, &posted.key), app: posted.package.clone(), talk, from: "+33199001234".into(), name: "Alice".into(), line_at, words: "J'arrive.".into(), at, ..Due::default() }
1481 }
1482
1483 #[test]
1487 fn a_notification_is_named_by_a_hash_made_on_the_phone() {
1488 let named = notice("org.thoughtcrime.securesms", "0|org.thoughtcrime.securesms|42|null|10123");
1489 assert_eq!(named, appnotes::talk_key("org.thoughtcrime.securesms", "0|org.thoughtcrime.securesms|42|null|10123"));
1490 assert_eq!(named, "a2b7df14a4081c04", "as AppNotes.talkKey makes it (android/jvm-checks/RepliesCheck.java)");
1491 assert!(!named.contains("10123"));
1492 assert_ne!(named, notice("org.thoughtcrime.securesms", "0|org.thoughtcrime.securesms|43|null|10123"), "another notification, another name");
1493 }
1494
1495 #[test]
1499 fn a_due_reply_is_taken_once_and_never_late() {
1500 let dir = scratch("take");
1501 let (posted, _) = signal(NOW - 5 * MINUTE, None);
1502 let first = due_for(&posted, "k1", NOW - 5 * MINUTE, NOW);
1503 let second = due_for(&posted, "k2", NOW - 5 * MINUTE, NOW - 3 * MINUTE);
1504 write_due(&dir, &[first.clone(), second.clone()], &Stand).unwrap();
1505 let on_disk = std::fs::read_to_string(dir.join(DUE)).unwrap();
1506 assert!(!on_disk.contains("J'arrive") && !on_disk.contains(&first.notice), "sealed at rest");
1507 assert_eq!(waiting(&dir, &Stand).len(), 2);
1508 assert_eq!(take(&dir, "k1", &Stand, NOW + 1_000).unwrap(), Taken::Go(Box::new(first)));
1509 assert_eq!(read_due(&dir, &Stand).iter().map(|d| (d.key.as_str(), d.words.as_str())).collect::<Vec<_>>(), [("k1", ""), ("k2", "J'arrive.")], "taken: its words no longer kept");
1510 assert_eq!(take(&dir, "k1", &Stand, NOW + 2_000).unwrap(), Taken::No, "once");
1511 assert_eq!(take(&dir, "k2", &Stand, NOW + 1_000).unwrap(), Taken::Late, "handed over three minutes before");
1512 assert_eq!(take(&dir, "k2", &Stand, NOW + 1_000).unwrap(), Taken::No);
1513 assert_eq!(take(&dir, "k3", &Stand, NOW).unwrap(), Taken::No, "not due");
1514 say(&dir, &Done { crossed: true, ..Done::of("k1", "handed", NOW + 3_000) }).unwrap();
1515 let done = read_done(&dir);
1516 assert_eq!(done.iter().map(|d| (d.key.as_str(), d.state.as_str())).collect::<Vec<_>>(), [("k1", "taken"), ("k2", "late"), ("k1", "handed")]);
1517 assert!(done[2].crossed);
1518 assert!(waiting(&dir, &Stand).is_empty());
1519 let said = std::fs::read_to_string(dir.join(DONE)).unwrap();
1520 assert!(!said.contains("J'arrive") && !said.contains("Alice"), "no words, no names: {said}");
1521 struct Other;
1523 impl Sealer for Other {
1524 fn seal(&self, plain: &str) -> String {
1525 plain.to_string()
1526 }
1527 fn open(&self, _: &str) -> Option<String> {
1528 None
1529 }
1530 }
1531 assert!(read_due(&dir, &Other).is_empty());
1532 let _ = std::fs::remove_dir_all(&dir);
1533 }
1534
1535 #[test]
1542 fn the_live_notification_must_be_the_conversation_answered() {
1543 let words = words();
1544 let choices = appnotes::Choices::default();
1545 let (posted, extra) = signal(NOW - 5 * MINUTE, None);
1546 let due = due_for(&posted, "k1", NOW - 5 * MINUTE, NOW);
1547 assert!(!due.talk.is_empty());
1548 assert_eq!(check(&due, &posted, &extra, &choices, &words, fr()), Checked::Go { crossed: false });
1549 let (newer, newer_extra) = signal(NOW - MINUTE, None);
1551 assert_eq!(check(&due, &newer, &newer_extra, &choices, &words, fr()), Checked::Go { crossed: true });
1552 assert_eq!(check(&due, &Posted { package: "org.telegram.messenger".into(), ..posted.clone() }, &extra, &choices, &words, fr()), Checked::Gone);
1554 assert_eq!(check(&due, &Posted { key: "0|org.thoughtcrime.securesms|43|null|10123".into(), ..posted.clone() }, &extra, &choices, &words, fr()), Checked::Gone);
1555 assert_eq!(check(&due, &Posted { shortcut: "bob-thread".into(), ..posted.clone() }, &extra, &choices, &words, fr()), Checked::Other, "another conversation under that notification");
1556 let bare = Due { talk: String::new(), ..due.clone() };
1558 assert_eq!(check(&bare, &posted, &extra, &choices, &words, fr()), Checked::Go { crossed: false });
1559 assert_eq!(check(&Due { from: "+33199009999".into(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Other);
1560 assert_eq!(check(&Due { from: String::new(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Go { crossed: false }, "her name");
1561 assert_eq!(check(&Due { from: String::new(), name: "Bob".into(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Other);
1562 assert_eq!(check(&Due { from: String::new(), name: String::new(), ..bare.clone() }, &posted, &extra, &choices, &words, fr()), Checked::Other, "nothing names the conversation");
1563 let untimed = Extra { messages: Vec::new(), ..newer_extra.clone() };
1565 assert_eq!(check(&due, &newer, &untimed, &choices, &words, fr()), Checked::Go { crossed: false }, "its own time moves with a silent repost");
1566 }
1567
1568 #[test]
1573 fn a_reply_never_goes_into_a_conversation_of_another_shape() {
1574 let words = words();
1575 let choices = appnotes::Choices::default();
1576 let alice = Person { name: "Alice".into(), key: "alice".into(), ..Person::default() };
1577 let bob = Person { name: "Bob".into(), key: "bob".into(), ..Person::default() };
1578 let base = Posted { key: "0|com.example.chat|7|null|10123".into(), package: "com.example.chat".into(), app: "Chat".into(), category: "msg".into(), template: "android.app.Notification$MessagingStyle".into(), is_conversation: true, ..Posted::default() };
1579 let one = Posted { title: "Alice".into(), messages: vec![Message { sender: Some(alice.clone()), text: "Tu passes ?".into() }], people: vec![alice.clone()], ..base.clone() };
1580 let group = Posted { messages: vec![Message { sender: Some(bob.clone()), text: "Qui vient ?".into() }, Message { sender: Some(alice.clone()), text: "Moi.".into() }], people: vec![bob.clone(), alice.clone()], ..base.clone() };
1581 let talk = |p: &Posted| match appnotes::classify(p, &choices, &words) {
1582 Kind::People(t) => t.conversation.unwrap(),
1583 other => panic!("{other:?}"),
1584 };
1585 let (mine, theirs) = (talk(&one), talk(&group));
1586 assert!(!mine.group && theirs.group);
1587 let due = Due { key: "k1".into(), verb: "reply".into(), notice: notice(&base.package, &base.key), app: base.package.clone(), talk: mine.key.clone(), name: "Alice".into(), words: "J'arrive.".into(), at: NOW, ..Due::default() };
1588 assert_eq!(check(&due, &one, &Extra::default(), &choices, &words, fr()), Checked::Go { crossed: false });
1589 assert_eq!(check(&due, &group, &Extra::default(), &choices, &words, fr()), Checked::Other, "her conversation, never the group");
1590 assert!(theirs.by_writer && mine.by_writer);
1593 let to_group = Due { talk: theirs.key.clone(), group: true, title: phonemsgs::title_of(&theirs), ..due.clone() };
1594 assert_eq!(check(&to_group, &group, &Extra::default(), &choices, &words, fr()), Checked::Other, "a group keyed by its last writer");
1595 assert_eq!(check(&to_group, &one, &Extra::default(), &choices, &words, fr()), Checked::Other, "the group's reply never goes to her alone");
1596 let titled = Posted { conversation: "École".into(), ..group.clone() };
1598 let school = talk(&titled);
1599 assert!(!school.by_writer);
1600 let to_school = Due { talk: school.key.clone(), group: true, title: phonemsgs::title_of(&school), ..due.clone() };
1601 assert_eq!(check(&to_school, &titled, &Extra::default(), &choices, &words, fr()), Checked::Go { crossed: false });
1602 assert_eq!(check(&Due { title: "Club".into(), ..to_school.clone() }, &titled, &Extra::default(), &choices, &words, fr()), Checked::Other, "another group's title");
1603 let line = phonemsgs::Line { group: true, title: "École".into(), talk: "aaaa".into(), ..alice_line() };
1605 let a = ask("k1", "J'arrive.", NOW);
1606 let d = due_of(&a, &line, NOW);
1607 assert_eq!((d.group, d.title.as_str()), (true, "École"));
1608 }
1609
1610 #[test]
1614 fn a_reply_is_shown_when_its_notification_shows_your_words() {
1615 let hash = words_hash("J'arrive dans dix minutes.");
1616 let taken = NOW + 30_000;
1617 let (before, extra) = signal(NOW, None);
1618 assert!(!shows(&before, &extra, &hash, taken, false));
1619 let (after, extra) = signal(NOW, Some("J'arrive dans dix minutes. "));
1621 assert!(shows(&after, &extra, &hash, taken, false), "your message, the trailing space aside");
1622 assert!(!shows(&after, &extra, &words_hash("Une autre réponse."), taken, false));
1623 assert!(!shows(&after, &extra, &hash, NOW + 5 * MINUTE, true), "an earlier message of yours, timed before");
1625 assert!(shown_already(&after, &extra, &hash));
1626 let (plain, extra) = signal(NOW, None);
1628 let history = Extra { history: vec!["J'arrive dans dix minutes.".into()], ..extra.clone() };
1629 assert!(shows(&plain, &history, &hash, taken, false), "the Reply's history");
1630 assert!(!shows(&plain, &history, &hash, taken, true), "there already before the reply");
1631 let (untimed, timed) = signal(NOW, Some("J'arrive dans dix minutes."));
1632 let untimed_extra = Extra { messages: Vec::new(), ..timed };
1633 assert!(shows(&untimed, &untimed_extra, &hash, taken, false));
1634 assert!(!shows(&untimed, &untimed_extra, &hash, taken, true));
1635 assert!(!shows(&plain, &extra, &words_hash("Je suis au café."), taken, false), "her words are not yours");
1636 }
1637
1638 #[test]
1642 fn what_a_notification_offers_is_said_once_per_change() {
1643 let root = scratch("live");
1644 let own = live_file(&root, "phone");
1645 let live = Live { notice: "0123456789abcdef".into(), app: "org.thoughtcrime.securesms".into(), talk: "fedcba9876543210".into(), reply: true, read: true, at: NOW, ..Live::default() };
1646 assert!(offer(&own, &live).unwrap());
1647 assert!(!offer(&own, &Live { at: NOW + MINUTE, ..live.clone() }).unwrap(), "the same offer: no line");
1648 assert!(offer(&own, &Live { read: false, at: NOW + 2 * MINUTE, ..live.clone() }).unwrap(), "Mark as read gone");
1649 assert!(went(&own, &live.notice, NOW + 3 * MINUTE).unwrap());
1650 assert!(!went(&own, &live.notice, NOW + 4 * MINUTE).unwrap(), "said once");
1651 assert!(!went(&own, "aaaaaaaaaaaaaaaa", NOW).unwrap(), "never relayed: nothing");
1652 assert!(offer(&own, &Live { at: NOW + 5 * MINUTE, ..live.clone() }).unwrap(), "posted again");
1653 let text = std::fs::read_to_string(&own).unwrap();
1654 assert_eq!(text.lines().count(), 4);
1655 assert!(!text.contains("Alice"));
1656 let read = read_live(&root, NOW + 6 * MINUTE);
1657 let last = read.get(&("phone".to_string(), live.notice.clone())).unwrap();
1658 assert_eq!((last.reply, last.read, last.gone, last.device.as_str()), (true, true, 0, "phone"));
1659 assert!(read_live(&root, NOW + 8 * DAY_MS).is_empty(), "a week");
1660 assert_eq!(phonemsgs::trim_own(&root, "phone", NOW + 8 * DAY_MS).unwrap(), 4);
1662 let _ = std::fs::remove_dir_all(&root);
1663 }
1664
1665 fn alice_line() -> phonemsgs::Line {
1667 phonemsgs::Line { at: NOW - 5 * MINUTE, id: format!("{}-aa11", NOW - 5 * MINUTE), app: "org.thoughtcrime.securesms".into(), label: "Signal".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Je suis au café.".into(), shows: NOW - 5 * MINUTE, ..phonemsgs::Line::default() }
1668 }
1669
1670 fn ask(key: &str, words: &str, written: i64) -> Ask {
1671 Ask { key: key.into(), phone: "phone".into(), verb: "reply".into(), notice: "0123456789abcdef".into(), app: "org.thoughtcrime.securesms".into(), line: alice_line().id, words: words.into(), written, device: "desk".into(), ..Ask::default() }
1672 }
1673
1674 #[test]
1678 fn a_request_travels_padded_and_sealed() {
1679 let root = scratch("asks");
1680 let first = ask("k1", "J'arrive.", NOW);
1681 assert!(write_ask(&root, "desk", &first, &Stand).unwrap());
1682 assert!(!write_ask(&root, "desk", &first, &Stand).unwrap(), "once");
1683 assert!(write_ask(&root, "desk", &Ask { verb: "cancel".into(), words: String::new(), ..first.clone() }, &Stand).unwrap(), "taken back: a line of its own");
1684 let text = std::fs::read_to_string(calls::own_file(&root, ASK, "desk")).unwrap();
1685 assert!(!text.contains("arrive") && !text.contains("securesms"), "sealed");
1686 assert!(text.lines().all(|l| Stand.open(l).is_some_and(|plain| plain.len() % PAD_STEP == 0)), "padded");
1687 let other = scratch("asks-copy");
1688 write_ask(&other, "desk", &first, &Stand).unwrap();
1689 write_ask(&other, "laptop", &ask("k2", "Bien reçu.", NOW), &Stand).unwrap();
1690 let (asks, cancelled) = read_asks(&[root.clone(), other.clone()], &Stand);
1691 assert_eq!(asks.iter().map(|a| (a.key.as_str(), a.device.as_str(), a.writer.as_str())).collect::<Vec<_>>(), [("k1", "desk", "desk"), ("k2", "laptop", "laptop")]);
1692 assert!(cancelled.contains(&("desk".to_string(), "k1".to_string())));
1693 assert_eq!(asks[0].words, "J'arrive.");
1694 let _ = std::fs::remove_dir_all(&root);
1695 let _ = std::fs::remove_dir_all(&other);
1696 }
1697
1698 #[test]
1703 fn a_request_moved_to_another_computer_s_file_is_passed_over() {
1704 let root = scratch("asks-moved");
1705 let first = ask("k1", "J'arrive.", NOW);
1706 write_ask(&root, "laptop", &first, &Stand).unwrap();
1707 write_ask(&root, "laptop", &Ask { verb: "cancel".into(), words: String::new(), ..first.clone() }, &Stand).unwrap();
1708 let moved = std::fs::read_to_string(calls::own_file(&root, ASK, "laptop")).unwrap();
1709 std::fs::write(calls::own_file(&root, ASK, "desk"), &moved).unwrap();
1711 let bare = format!("{}\n", Stand.seal(&serde_json::to_string(&Ask { key: "k2".into(), device: String::new(), ..ask("k2", "Oui.", NOW) }).unwrap()));
1712 std::fs::write(calls::own_file(&root, ASK, "phone"), bare).unwrap();
1713 let (asks, cancelled) = read_asks(std::slice::from_ref(&root), &Stand);
1714 assert_eq!(asks.iter().map(|a| (a.key.as_str(), a.device.as_str())).collect::<Vec<_>>(), [("k1", "laptop")], "the desk's moved copy and the bare line passed over");
1715 assert_eq!(cancelled.into_iter().collect::<Vec<_>>(), [("laptop".to_string(), "k1".to_string())]);
1716 let _ = std::fs::remove_dir_all(&root);
1717 }
1718
1719 #[test]
1725 fn alice_s_reply_never_goes_to_bob() {
1726 let tr = crate::i18n::Translator::new("en");
1727 let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
1728 let bob = phonemsgs::Line { at: NOW - MINUTE, id: format!("{}-bb33", NOW - MINUTE), notice: "1111222233334444".into(), talk: "0000111122223333".into(), name: "Bob".into(), key: "+33261910000".into(), text: "Salut !".into(), ..line.clone() };
1730 let code = phonemsgs::Line { at: NOW - MINUTE / 2, id: format!("{}-cc44", NOW - MINUTE / 2), notice: "1111222233334444".into(), code: true, text: String::new(), ..line.clone() };
1731 let porch = phonemsgs::PorchLine { ids: vec![line.id.clone()], head: String::new(), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known: true, texted: false, code: false, who: "Alice".into(), answer: None };
1732 let live = |notice: &str, reply: bool, gone: i64| (("phone".to_string(), notice.to_string()), Live { notice: notice.into(), app: line.app.clone(), talk: line.talk.clone(), reply, read: reply, gone, at: NOW, device: "phone".into() });
1733 let lives: BTreeMap<_, _> = [live(&line.notice, false, NOW), live("1111222233334444", true, 0)].into();
1734 let (shared, time, none, no_one, nobody) = (|_: &str| Some(NOW), |_: i64| "14:03".to_string(), |_: &str, _: &str, _: i64| Vec::new(), |_: &str| false, BTreeSet::new());
1735 for lines in [vec![line.clone(), bob.clone()], vec![line.clone(), code.clone()], vec![line.clone(), bob.clone(), code.clone()]] {
1736 let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
1737 let answer = answer_of(&porch, &[&line], &o).unwrap();
1738 assert!(!answer.offered, "no line of Alice's conversation in that notification: Reply on your phone");
1739 assert!(answer.line != bob.id && answer.line != code.id, "never Bob's line, never a code's: {}", answer.line);
1740 }
1741 let hers = phonemsgs::Line { at: NOW - MINUTE / 4, id: format!("{}-dd55", NOW - MINUTE / 4), notice: "1111222233334444".into(), text: "Tu es où ?".into(), ..line.clone() };
1743 let lines = vec![line.clone(), bob.clone(), hers.clone(), code];
1744 let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
1745 let answer = answer_of(&porch, &[&line], &o).unwrap();
1746 assert_eq!((answer.offered, answer.line.as_str()), (true, hers.id.as_str()));
1747 let group = phonemsgs::Line { group: true, title: "Alice".into(), ..line.clone() };
1750 let one = phonemsgs::Line { at: NOW - MINUTE / 4, id: format!("{}-ee66", NOW - MINUTE / 4), notice: "1111222233334444".into(), text: "Seule.".into(), ..line.clone() };
1751 let lines = vec![group.clone(), one];
1752 let gp = phonemsgs::PorchLine { who: "Alice".into(), ..porch.clone() };
1753 let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
1754 let answer = answer_of(&gp, &[&group], &o).unwrap();
1755 assert!(!answer.offered, "a group's reply never redirected to her one-to-one");
1756 }
1757
1758 #[test]
1763 fn a_file_of_requests_is_read_from_where_the_phone_was_done_with_it() {
1764 let root = scratch("read-marks");
1765 let mine = |key: &str| ask(key, "Oui.", NOW);
1766 write_ask(&root, "desk", &mine("k1"), &Stand).unwrap();
1767 write_ask(&root, "desk", &Ask { phone: "other-phone".into(), ..mine("k2") }, &Stand).unwrap();
1768 write_ask(&root, "desk", &mine("k3"), &Stand).unwrap();
1769 let roots = vec![root.clone()];
1770 let keys = |asked: &(Vec<Ask>, BTreeSet<(String, String)>)| asked.0.iter().map(|a| a.key.clone()).collect::<Vec<_>>();
1771 let (asked, reads) = read_asks_after(&roots, &Stand, &BTreeMap::new());
1772 assert_eq!(keys(&asked), ["k1", "k2", "k3"]);
1773 let decided = |set: &'static [&'static str]| move |key: &str, phone: &str| phone != "phone" || set.contains(&key);
1775 let marks = moved_marks(&reads, &decided(&["k1"]), &BTreeMap::new());
1776 let (asked, reads) = read_asks_after(&roots, &Stand, &marks);
1777 assert_eq!(keys(&asked), ["k3"], "k1 and k2 never opened again");
1778 write_ask(&root, "desk", &Ask { verb: "cancel".into(), words: String::new(), ..mine("k3") }, &Stand).unwrap();
1780 let (asked, reads) = read_asks_after(&roots, &Stand, &moved_marks(&reads, &decided(&["k1"]), &marks));
1781 assert_eq!((keys(&asked), asked.1.len()), (vec!["k3".to_string()], 1));
1782 let marks = moved_marks(&reads, &decided(&["k1", "k3"]), &marks);
1783 let (asked, reads) = read_asks_after(&roots, &Stand, &marks);
1784 assert!(asked.0.is_empty() && asked.1.is_empty() && reads[0].lines.is_empty(), "done with the whole file");
1785 let own = calls::own_file(&root, ASK, "desk");
1787 std::fs::write(&own, format!("{}not a sealed line\n", std::fs::read_to_string(&own).unwrap())).unwrap();
1788 let marks = moved_marks(&reads, &decided(&["k1", "k3"]), &marks);
1789 let (_, reads) = read_asks_after(&roots, &Stand, &marks);
1790 let marks = moved_marks(&reads, &decided(&["k1", "k3"]), &marks);
1791 assert_eq!(marks.values().next().unwrap().offset, std::fs::metadata(&own).unwrap().len());
1792 let text = std::fs::read_to_string(&own).unwrap();
1794 std::fs::write(&own, text.split_inclusive('\n').skip(1).collect::<String>()).unwrap();
1795 let (asked, _) = read_asks_after(&roots, &Stand, &marks);
1796 assert_eq!(keys(&asked), ["k2", "k3"], "the mark's line is no longer there: the whole file");
1797 let _ = std::fs::remove_dir_all(&root);
1798 }
1799
1800 #[test]
1802 fn each_computer_s_requests_come_in_turn() {
1803 let of = |key: &str, device: &str| Ask { device: device.into(), ..ask(key, "Oui.", NOW) };
1804 let asks = vec![of("a1", "aaa"), of("a2", "aaa"), of("a3", "aaa"), of("d1", "desk"), Ask { phone: "other".into(), ..of("o1", "desk") }];
1805 assert_eq!(in_turn(&asks, "phone").iter().map(|a| a.key.as_str()).collect::<Vec<_>>(), ["a1", "d1", "a2", "a3"]);
1806 assert!(in_turn(&asks, "nobody").is_empty());
1807 }
1808
1809 #[test]
1816 fn a_request_is_decided_once_as_texts_are() {
1817 let dir = scratch("verdict");
1818 let ledger = crate::texts::Ledger::open(&dir, NOW - 60 * MINUTE).unwrap();
1819 let lines = vec![alice_line(), phonemsgs::Line { id: "code".into(), code: true, ..alice_line() }];
1820 let none = BTreeSet::new();
1821 let nothing = BTreeSet::new();
1822 let yes = |_: &str| true;
1823 let counted = |_: &str| crate::texts::Standing::Counted;
1824 let judge = |ledger: &crate::texts::Ledger, cancelled: &BTreeSet<(String, String)>, standing: &dyn Fn(&str) -> crate::texts::Standing, answers: &dyn Fn(&str) -> bool, lines: &[phonemsgs::Line], a: &Ask| {
1825 verdict(a, &Judge { here: "phone", now_ms: NOW, ledger, lines, standing, cancelled, answers, unsent: ¬hing, went: ¬hing })
1826 };
1827 let v = |a: &Ask| judge(&ledger, &none, &counted, &yes, &lines, a);
1828 let good = ask("k1", "J'arrive.", NOW - MINUTE);
1829 assert_eq!(v(&good), Verdict::Send);
1830 assert_eq!(v(&Ask { phone: "other-phone".into(), ..good.clone() }), Verdict::NotMine);
1831 assert_eq!(v(&Ask { verb: "cancel".into(), ..good.clone() }), Verdict::NotMine, "read with what it takes back");
1832 let cancelled: BTreeSet<(String, String)> = [("desk".to_string(), "k1".to_string())].into();
1833 assert_eq!(judge(&ledger, &cancelled, &counted, &yes, &lines, &good), Verdict::TakenBack);
1834 assert_eq!(judge(&ledger, &cancelled, &counted, &yes, &lines, &Ask { device: "laptop".into(), ..good.clone() }), Verdict::Send, "another computer cannot take it back");
1835 let gone = |_: &str| crate::texts::Standing::Gone;
1836 assert_eq!(judge(&ledger, &none, &gone, &yes, &lines, &good), Verdict::Refused("not-counted"));
1837 let unknown = |_: &str| crate::texts::Standing::Unknown;
1839 assert_eq!(judge(&ledger, &none, &unknown, &yes, &lines, &good), Verdict::Wait);
1840 assert_eq!(judge(&ledger, &none, &unknown, &yes, &lines, &ask("k9", "J'arrive.", NOW - 16 * MINUTE)), Verdict::Expired);
1841 assert_eq!(v(&Ask { verb: "delete".into(), ..good.clone() }), Verdict::Refused("verb"));
1842 assert_eq!(v(&Ask { words: " ".into(), ..good.clone() }), Verdict::Refused("no-words"));
1843 assert_eq!(v(&Ask { words: "é".repeat(WORDS_MAX + 1), ..good.clone() }), Verdict::Refused("too-long"));
1844 assert_eq!(v(&Ask { verb: "read".into(), words: String::new(), ..good.clone() }), Verdict::Send, "Mark as read: no words");
1845 assert_eq!(v(&Ask { line: "nowhere".into(), ..good.clone() }), Verdict::Refused("line"));
1846 assert_eq!(v(&Ask { notice: "ffffffffffffffff".into(), ..good.clone() }), Verdict::Refused("line"), "another notification than the line's");
1847 assert_eq!(v(&Ask { line: "code".into(), ..good.clone() }), Verdict::Refused("code"));
1848 assert_eq!(judge(&ledger, &none, &counted, &|app: &str| app != "org.thoughtcrime.securesms", &lines, &good), Verdict::Refused("switched-off"));
1849 let service = vec![phonemsgs::Line { kind: "text".into(), app: "foundation.e.message".into(), who: "automaton".into(), key: String::new(), name: "AMELI".into(), ..alice_line() }];
1850 assert_eq!(judge(&ledger, &none, &counted, &yes, &service, &Ask { app: "foundation.e.message".into(), ..good.clone() }), Verdict::Refused("short-number"));
1851 let short = vec![phonemsgs::Line { kind: "text".into(), app: "foundation.e.message".into(), key: "36179".into(), ..alice_line() }];
1852 assert_eq!(judge(&ledger, &none, &counted, &yes, &short, &Ask { app: "foundation.e.message".into(), ..good.clone() }), Verdict::Refused("short-number"));
1853 assert_eq!(v(&Ask { written: NOW - 2 * 60 * MINUTE, ..good.clone() }), Verdict::Refused("before-mark"));
1854 assert_eq!(v(&Ask { written: NOW + 5 * MINUTE, ..good.clone() }), Verdict::Clocks);
1855 assert_eq!(v(&Ask { written: NOW + 5 * MINUTE, ahead: 4 * MINUTE, ..good.clone() }), Verdict::Send, "the writer's clock ahead, corrected");
1856 assert_eq!(v(&Ask { written: NOW - 16 * MINUTE, ..good.clone() }), Verdict::Expired);
1857 crate::texts::record(&dir, &claim_of(&good, "", NOW - MINUTE)).unwrap();
1859 let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1860 let v = |a: &Ask| judge(&ledger, &none, &counted, &yes, &lines, a);
1861 assert_eq!(v(&good), Verdict::Decided);
1862 assert!(!std::fs::read_to_string(dir.join(crate::texts::LEDGER)).unwrap().contains("arrive"), "the ledger keeps a hash, never the words");
1863 assert_eq!(v(&ask("k2", "J'arrive. ", NOW)), Verdict::Refused("same-words-pending"), "on its way: no word of the phone yet");
1865 assert_eq!(v(&Ask { again: "k1".into(), ..ask("k2", "J'arrive.", NOW) }), Verdict::Send);
1866 assert_eq!(v(&ask("k2", "Dans cinq minutes.", NOW)), Verdict::Send);
1867 for i in 0..PER_HOUR {
1869 crate::texts::record(&dir, &claim_of(&ask(&format!("n{i}"), &format!("Réponse {i}"), NOW), "", NOW - 30 * MINUTE)).unwrap();
1870 }
1871 let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1872 assert_eq!(judge(&ledger, &none, &counted, &yes, &lines, &ask("k3", "Encore une.", NOW)), Verdict::Refused("too-many"));
1873 assert_eq!(judge(&ledger, &none, &counted, &yes, &lines, &Ask { verb: "read".into(), words: String::new(), ..ask("k4", "", NOW) }), Verdict::Send, "a Mark as read counts for none");
1874 let read = |key: &str| Ask { verb: "read".into(), words: String::new(), ..ask(key, "", NOW) };
1876 let claims: Vec<crate::texts::Claim> = (0..PER_HOUR).map(|i| claim_of(&read(&format!("r{i}")), "", NOW - 20 * MINUTE)).collect();
1877 crate::texts::record_all(&dir, &claims).unwrap();
1878 let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1879 assert_eq!(judge(&ledger, &none, &counted, &yes, &lines, &read("r99")), Verdict::Refused("too-many-read"));
1880 let _ = std::fs::remove_dir_all(&dir);
1881 }
1882
1883 #[test]
1888 fn the_same_words_go_again_when_the_first_never_went() {
1889 let dir = scratch("same-words");
1890 crate::texts::Ledger::open(&dir, NOW - 60 * MINUTE).unwrap();
1891 let lines = vec![alice_line()];
1892 let (none, yes) = (BTreeSet::new(), |_: &str| true);
1893 let first = ask("k1", "J'arrive.", NOW - 3 * MINUTE);
1894 crate::texts::record(&dir, &claim_of(&first, &alice_line().talk, NOW - 3 * MINUTE)).unwrap();
1895 let again = ask("k2", "J'arrive.", NOW);
1896 let v = |listener: &[(&str, &str)]| {
1897 let folder = scratch("same-words-done");
1898 for (key, state) in listener {
1899 say(&folder, &Done::of(key, state, NOW - 2 * MINUTE)).unwrap();
1900 }
1901 let (unsent, went) = (unsent(&folder), went_keys(&folder));
1902 let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1903 let said = verdict(&again, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent: &unsent, went: &went });
1904 let _ = std::fs::remove_dir_all(&folder);
1905 said
1906 };
1907 assert_eq!(v(&[]), Verdict::Refused("same-words-pending"), "claimed, not said yet: it may go");
1909 for (word, said) in [("taken", "same-words-pending"), ("doubt", "same-words-pending"), ("handed", "same-words"), ("shown", "same-words")] {
1910 assert_eq!(v(&[("k1", "taken"), ("k1", word)]), Verdict::Refused(said), "{word}");
1911 }
1912 for not in NOT_SENT {
1913 assert_eq!(v(&[("k1", "taken"), ("k1", not)]), Verdict::Send, "{not}: it never went");
1914 }
1915 let newer = phonemsgs::Line { id: format!("{}-bb22", NOW - MINUTE), at: NOW - MINUTE, notice: "1111222233334444".into(), ..alice_line() };
1918 let lines = vec![alice_line(), newer.clone()];
1919 let ledger = crate::texts::Ledger::open(&dir, NOW).unwrap();
1920 let through_newer = |a: &Ask, unsent: &BTreeSet<String>| verdict(a, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent, went: &none_keys() });
1921 let fresh = Ask { notice: newer.notice.clone(), line: newer.id.clone(), ..ask("k3", "J'arrive.", NOW) };
1922 assert_eq!(through_newer(&fresh, &BTreeSet::new()), Verdict::Refused("same-words-pending"), "a fresh reply through the newer notification");
1923 let again = Ask { again: "k2".into(), ..fresh.clone() };
1924 assert_eq!(through_newer(&again, &BTreeSet::new()), Verdict::Refused("same-words-pending"), "Send again of the refused one through the newer notification");
1925 let first_went: BTreeSet<String> = ["k1".to_string()].into();
1926 let through_newer_after = |a: &Ask| verdict(a, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent: &BTreeSet::new(), went: &first_went });
1927 assert_eq!(through_newer_after(&again), Verdict::Refused("same-words"), "the first went: never twice");
1928 let gone: BTreeSet<String> = ["k1".to_string()].into();
1929 assert_eq!(through_newer(&again, &gone), Verdict::Send, "the first never went: it goes, once");
1930 let _ = std::fs::remove_dir_all(&dir);
1931 }
1932
1933 fn none_keys() -> BTreeSet<String> {
1934 BTreeSet::new()
1935 }
1936
1937 #[test]
1942 fn the_mark_is_made_once_before_the_first_request() {
1943 let dir = scratch("mark").join("replies");
1944 mark_ledger(&dir, NOW - 10 * MINUTE).unwrap();
1945 mark_ledger(&dir, NOW).unwrap();
1946 let ledger = open_ledger(&dir, NOW).unwrap();
1947 assert_eq!(ledger.mark.since, NOW - 10 * MINUTE, "never moved");
1948 #[cfg(unix)]
1949 {
1950 use std::os::unix::fs::PermissionsExt;
1951 assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700, "yours alone");
1952 }
1953 let lines = vec![alice_line()];
1954 let (none, nothing, yes) = (BTreeSet::new(), BTreeSet::new(), |_: &str| true);
1955 let v = |a: &Ask| verdict(a, &Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &none, answers: &yes, unsent: ¬hing, went: ¬hing });
1956 assert_eq!(v(&ask("k1", "J'arrive.", NOW - 90_000)), Verdict::Send, "written after the mark");
1957 assert_eq!(v(&ask("k2", "J'arrive.", NOW - 11 * MINUTE)), Verdict::Refused("before-mark"));
1958 let _ = std::fs::remove_dir_all(dir.parent().unwrap());
1959 }
1960
1961 #[test]
1965 fn what_the_listener_did_becomes_the_phone_s_word_once() {
1966 let dir = scratch("settle");
1967 let line = alice_line();
1968 let (a, b, c) = (ask("k1", "J'arrive.", NOW), ask("k2", "Merci.", NOW), ask("k3", "Oui.", NOW));
1969 add_due(&dir, &[due_of(&a, &line, NOW), due_of(&b, &line, NOW), due_of(&c, &line, NOW)], &Stand, NOW).unwrap();
1970 assert_eq!(read_due(&dir, &Stand)[0].from, "+33199001234");
1971 assert_eq!(read_due(&dir, &Stand)[0].line_at, line.at);
1972 assert!(matches!(take(&dir, "k1", &Stand, NOW + 1_000).unwrap(), Taken::Go(_)));
1973 say(&dir, &Done::of("k1", "handed", NOW + 2_000)).unwrap();
1974 assert!(matches!(take(&dir, "k2", &Stand, NOW + 1_000).unwrap(), Taken::Go(_)));
1975 let claimed = vec![("k1".to_string(), NOW), ("k2".to_string(), NOW), ("k3".to_string(), NOW)];
1976 let done = settle(&dir, &claimed, NOW + 3 * MINUTE).unwrap();
1977 let states: Vec<(&str, &str)> = done.iter().map(|d| (d.key.as_str(), d.state.as_str())).collect();
1978 assert!(states.contains(&("k2", "doubt")) && states.contains(&("k3", "late")), "{states:?}");
1979 assert!(!states.contains(&("k1", "doubt")));
1980 assert_eq!(take(&dir, "k3", &Stand, NOW + 3 * MINUTE).unwrap(), Taken::No, "late is the end");
1981 let root = scratch("outcomes");
1983 let own = calls::own_file(&root, OUTCOME, "phone");
1984 assert_eq!(say_outcomes(&own, &outcomes_of(&done)).unwrap(), done.len());
1985 assert_eq!(say_outcomes(&own, &outcomes_of(&done)).unwrap(), 0, "once");
1986 let text = std::fs::read_to_string(&own).unwrap();
1987 assert!(!text.contains("arrive") && !text.contains("Alice"));
1988 let read = read_outcomes(&root);
1989 assert!(read.iter().all(|o| o.device == "phone"));
1990 trim_private(&dir, &Stand, NOW + 2 * DAY_MS).unwrap();
1992 assert!(read_due(&dir, &Stand).is_empty() && read_done(&dir).is_empty());
1993 let other = calls::own_file(&root, OUTCOME, "old-phone");
1994 say_outcomes(&other, &[Outcome { key: "k9".into(), state: "handed".into(), at: NOW, ..Outcome::default() }]).unwrap();
1995 write_ask(&root, "phone", &a, &Stand).unwrap();
1996 assert_eq!(trim_shared(&root, "phone", &Stand, NOW + 8 * DAY_MS).unwrap(), done.len() + 1, "the phone's own, its outcomes and its request");
1997 assert_eq!(read_outcomes(&root).len(), 1, "another's waits three more days");
1998 assert_eq!(trim_shared(&root, "phone", &Stand, NOW + 11 * DAY_MS).unwrap(), 1);
1999 let _ = std::fs::remove_dir_all(&dir);
2000 let _ = std::fs::remove_dir_all(&root);
2001 }
2002
2003 #[test]
2008 fn each_state_is_said_in_words() {
2009 let at = |o: &str, reason: &str| Outcome { key: "k1".into(), state: o.into(), at: NOW, reason: reason.into(), ..Outcome::default() };
2010 let time = |_: i64| "14:03".to_string();
2011 for language in ["en", "fr"] {
2012 let tr = crate::i18n::Translator::new(language);
2013 let a = ask("k1", "J'arrive.", NOW);
2014 let waiting = state_of(&a, &[], "Signal", &tr, Some(NOW), false, &time);
2015 assert!(waiting.waiting && waiting.said.contains("14:03"), "{language}: {waiting:?}");
2016 assert!(state_of(&a, &[], "Signal", &tr, None, false, &time).waiting);
2017 let states = ["taken", "handed", "shown", "gone", "other", "no-reply", "no-read", "closed", "locked", "activity", "late", "failed", "expired", "clocks", "doubt", "taken-back"];
2018 for state in states {
2019 let o = at(state, "");
2020 let said = state_of(&a, &[&o], "Signal", &tr, Some(NOW), false, &time);
2021 assert!(!said.waiting && !said.said.starts_with("replies-") && !said.said.is_empty(), "{language} {state}: {said:?}");
2022 assert!(!said.said.to_lowercase().contains("sent at") && !said.said.contains("Envoyé"), "never a plain Sent: {language} {state}");
2023 }
2024 for reason in ["same-words", "same-words-pending", "too-many", "too-many-read", "too-long", "no-words", "line", "code", "switched-off", "short-number", "not-counted", "before-mark"] {
2025 let o = at("refused", reason);
2026 let said = state_of(&a, &[&o], "Signal", &tr, Some(NOW), false, &time);
2027 assert!(!said.said.starts_with("replies-"), "{language} {reason}");
2028 }
2029 let (handed, shown) = (at("handed", ""), Outcome { at: NOW + 1, ..at("shown", "") });
2030 let said = |a: &Ask, o: &[&Outcome], cancel: bool| state_of(a, o, "Signal", &tr, None, cancel, &time);
2031 assert_eq!(said(&a, &[&handed, &shown], false).state, "shown", "the furthest it went");
2032 let gone = said(&a, &[&at("gone", "")], false);
2033 assert!(gone.on_phone && !gone.again);
2034 assert!(said(&a, &[&at("other", "")], false).on_phone);
2035 assert!(said(&a, &[&at("expired", "")], false).again);
2036 let crossed = Outcome { crossed: true, ..at("handed", "") };
2037 assert_ne!(said(&a, &[&crossed], false).said, said(&a, &[&handed], false).said, "crossed said");
2038 let held = Outcome { held: true, ..at("handed", "") };
2040 assert_eq!(said(&a, &[&held], false).said, said(&a, &[&handed], false).said, "{language}: held, nothing newer");
2041 let both = Outcome { held: true, crossed: true, ..at("handed", "") };
2042 assert_ne!(said(&a, &[&both], false).said, said(&a, &[&crossed], false).said);
2043 let read = Ask { verb: "read".into(), words: String::new(), ..a.clone() };
2045 let read_said = said(&read, &[&handed], false).said;
2046 assert_ne!(read_said, said(&a, &[&handed], false).said, "a Mark as read's own words");
2047 if language == "en" {
2048 assert_eq!(read_said, "Mark as read handed to Signal at 14:03.");
2049 assert_eq!(said(&a, &[&both], false).said, "Handed to Signal at 14:03. A newer message of theirs waits on your phone.");
2050 }
2051 let asked = said(&a, &[], true);
2053 assert!(!asked.waiting && !asked.said.is_empty() && !asked.said.starts_with("replies-"), "{language}: {asked:?}");
2054 for went in ["taken", "handed", "shown", "doubt"] {
2055 let late = said(&a, &[&at(went, "")], true);
2056 assert!(late.said.len() > said(&a, &[&at(went, "")], false).said.len(), "{language} {went}: too late said");
2057 }
2058 assert_eq!(said(&a, &[&at("taken-back", "")], true).said, said(&a, &[&at("taken-back", "")], false).said);
2059 if language == "en" {
2060 assert_eq!(asked.said, "Taking it back: your phone will not send it, unless it has taken it already.");
2061 assert_eq!(said(&a, &[&handed], true).said, "Handed to Signal at 14:03. Take back came too late: your phone had taken it already.");
2062 }
2063 }
2064 }
2065
2066 #[test]
2068 fn a_conversation_answered_is_let_through_for_twenty_minutes() {
2069 let dir = scratch("answered");
2070 assert!(!answered_lately(&dir, "fedcba9876543210", NOW));
2071 note_answered(&dir, "fedcba9876543210", NOW).unwrap();
2072 note_answered(&dir, "", NOW).unwrap();
2073 assert!(answered_lately(&dir, "fedcba9876543210", NOW + 19 * MINUTE));
2074 assert!(!answered_lately(&dir, "fedcba9876543210", NOW + 21 * MINUTE));
2075 assert!(!answered_lately(&dir, "0000000000000000", NOW), "another conversation");
2076 assert!(!answered_lately(&dir, "", NOW));
2077 note_answered(&dir, &answered_number("+33199001234"), NOW).unwrap();
2079 assert!(answered_lately(&dir, &answered_number("+33199001234"), NOW + MINUTE));
2080 assert!(!answered_lately(&dir, &answered_number("+33261910000"), NOW + MINUTE));
2081 assert!(!std::fs::read_to_string(dir.join(ANSWERED)).unwrap().contains("199001234"));
2082 assert_eq!(answered_number(""), "");
2083 let held = appnotes::Decision::held(NOW / 1000 + 3600, appnotes::Why::Waiting);
2085 assert_eq!(held.answered(), appnotes::Decision::through(appnotes::Why::Answered));
2086 assert_eq!(appnotes::Decision::held(NOW / 1000 + 3600, appnotes::Why::Gathered).answered().why, appnotes::Why::Answered);
2087 let never = appnotes::Decision::held(NOW / 1000 + 3600, appnotes::Why::Never);
2088 assert_eq!(never.answered(), never, "the blocked, a conversation set to never");
2089 let through = appnotes::Decision::through(appnotes::Why::Allowed);
2090 assert_eq!(through.answered(), through);
2091 let _ = std::fs::remove_dir_all(&dir);
2092 }
2093
2094 #[test]
2099 fn what_this_computer_answered_lately_shows_at_once() {
2100 let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
2101 let mine = ask("k1", "J'arrive.", NOW);
2102 let outcome = |key: &str, state: &str, at: i64| Outcome { key: key.into(), state: state.into(), at, device: "phone".into(), ..Outcome::default() };
2103 let all = [line.clone()];
2104 let answered = Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW)], &all, NOW + 5 * MINUTE);
2105 assert!(answered.holds(&line));
2106 assert!(answered.holds(&phonemsgs::Line { id: "2-bb".into(), text: "Tu es où ?".into(), ..line.clone() }), "their answer, in the same conversation");
2107 assert!(!answered.holds(&phonemsgs::Line { device: "other".into(), ..line.clone() }), "another phone's");
2108 assert!(!answered.holds(&phonemsgs::Line { talk: "0000000000000000".into(), ..line.clone() }), "another conversation");
2109 assert!(Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "shown", NOW)], &all, NOW + 19 * MINUTE).holds(&line));
2110 assert!(Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW + MINUTE)], &all, NOW).holds(&line), "the phone's clock a minute ahead");
2111 assert!(!Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW + 3 * MINUTE)], &all, NOW).holds(&line), "three minutes ahead: not that reply");
2112 assert!(!Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "handed", NOW)], &all, NOW + 21 * MINUTE).holds(&line), "past twenty minutes");
2113 assert!(!Answered::of(std::slice::from_ref(&mine), &[outcome("k1", "refused", NOW)], &all, NOW).holds(&line));
2114 assert!(!Answered::of(std::slice::from_ref(&mine), &[], &all, NOW).holds(&line), "still waiting");
2115 assert!(!Answered::of(&[], &[outcome("k1", "handed", NOW)], &all, NOW).holds(&line), "not this computer's");
2116 let forged = Outcome { device: "other".into(), ..outcome("k1", "handed", NOW) };
2117 assert!(!Answered::of(std::slice::from_ref(&mine), &[forged], &all, NOW).holds(&line), "said by another phone");
2118 let read = Ask { verb: "read".into(), words: String::new(), ..mine.clone() };
2119 assert!(!Answered::of(&[read], &[outcome("k1", "handed", NOW)], &all, NOW).holds(&line), "a Mark as read is no answer");
2120 let mut texted = Answered::default();
2122 texted.texted("+33199001234".into());
2123 texted.texted(String::new());
2124 let text = phonemsgs::Line { kind: "text".into(), talk: String::new(), app: "foundation.e.message".into(), ..line.clone() };
2125 assert!(texted.holds(&text));
2126 assert!(!texted.holds(&line), "Signal's line from the same number");
2127 assert!(!texted.holds(&phonemsgs::Line { key: "+33261910000".into(), ..text.clone() }));
2128 }
2129
2130 #[test]
2139 fn a_porch_line_offers_a_reply_while_the_phone_can_send_it() {
2140 let tr = crate::i18n::Translator::new("en");
2141 let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
2142 let porch = phonemsgs::PorchLine { ids: vec![line.id.clone()], head: "Alice wrote on Signal:".into(), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known: true, texted: false, code: false, who: "Alice".into(), answer: None };
2143 let live = |notice: &str, reply: bool, gone: i64, at: i64| (("phone".to_string(), notice.to_string()), Live { notice: notice.into(), app: line.app.clone(), talk: line.talk.clone(), reply, read: reply, gone, at, device: "phone".into() });
2144 let shared = |_: &str| Some(NOW);
2145 let time = |_: i64| "14:03".to_string();
2146 let none = |_: &str, _: &str, _: i64| Vec::new();
2147 let (no_one, nobody) = (|_: &str| false, BTreeSet::new());
2148 let all = vec![line.clone()];
2149 let offer = |live: &BTreeMap<(String, String), Live>, asks: &[Ask], outcomes: &[Outcome], taken_back: &BTreeSet<String>| {
2150 let o = Offer { live, asks, outcomes, tr: &tr, taken_back, lines: &all, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2151 answer_of(&porch, &[&line], &o)
2152 };
2153 let open: BTreeMap<_, _> = [live(&line.notice, true, 0, NOW)].into();
2154 let answer = offer(&open, &[], &[], &nobody).unwrap();
2155 assert!(answer.offered && answer.read && !answer.texts);
2156 assert_eq!((answer.placeholder.as_str(), answer.label.as_str(), answer.phone.as_str(), answer.line.as_str()), ("Reply to Alice on Signal…", "Signal", "phone", line.id.as_str()));
2157 assert!(!answer.note.is_empty(), "Signal marks the conversation read when it replies");
2158 assert_eq!(answer.limit, WORDS_MAX);
2159 let gone: BTreeMap<_, _> = [live(&line.notice, false, NOW, NOW)].into();
2161 assert!(!offer(&gone, &[], &[], &nobody).unwrap().offered);
2162 let anew: BTreeMap<_, _> = [live(&line.notice, false, NOW, NOW), live("1111222233334444", true, 0, NOW + MINUTE)].into();
2164 assert!(!offer(&anew, &[], &[], &nobody).unwrap().offered, "no line of the newer notification here");
2165 let newer = phonemsgs::Line { at: NOW - MINUTE, id: format!("{}-bb22", NOW - MINUTE), notice: "1111222233334444".into(), text: "Tu es où ?".into(), ..line.clone() };
2166 let with_newer = vec![line.clone(), newer.clone()];
2167 let o = Offer { live: &anew, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &with_newer, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2168 let answer = answer_of(&porch, &[&line], &o).unwrap();
2169 assert_eq!((answer.offered, answer.notice.as_str(), answer.line.as_str()), (true, "1111222233334444", newer.id.as_str()));
2170 assert!(!offer(&BTreeMap::new(), &[], &[], &nobody).unwrap().offered);
2172 let service = phonemsgs::Line { kind: "text".into(), app: "foundation.e.message".into(), who: "automaton".into(), key: String::new(), name: "AMELI".into(), ..line.clone() };
2174 let o = Offer { live: &open, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2175 let said = answer_of(&porch, &[&service], &o).unwrap();
2176 assert!(!said.offered && !said.read, "the phone would refuse it");
2177 let short = phonemsgs::Line { who: "neutral".into(), key: "36179".into(), ..service.clone() };
2178 assert!(!answer_of(&porch, &[&short], &o).unwrap().offered);
2179 let mine = Ask { phone: "phone".into(), ..ask("k1", "J'arrive.", NOW) };
2181 let handed = Outcome { key: "k1".into(), state: "handed".into(), at: NOW, device: "phone".into(), ..Outcome::default() };
2182 let answer = offer(&open, std::slice::from_ref(&mine), std::slice::from_ref(&handed), &nobody).unwrap();
2183 assert_eq!(answer.mine.iter().map(|m| (m.text.as_str(), m.said.as_str(), m.waiting)).collect::<Vec<_>>(), [("J'arrive.", "Handed to Signal at 14:03.", false)]);
2184 let waiting = offer(&open, std::slice::from_ref(&mine), &[], &nobody).unwrap();
2185 assert!(waiting.mine[0].waiting, "Take back while it waits");
2186 let back: BTreeSet<String> = ["k1".to_string()].into();
2187 let taking = offer(&open, std::slice::from_ref(&mine), &[], &back).unwrap();
2188 assert_eq!((taking.mine[0].waiting, taking.mine[0].said.as_str()), (false, "Taking it back: your phone will not send it, unless it has taken it already."));
2189 let late = offer(&open, std::slice::from_ref(&mine), std::slice::from_ref(&handed), &back).unwrap();
2190 assert!(late.mine[0].said.ends_with("Take back came too late: your phone had taken it already."));
2191 let texted = phonemsgs::PorchLine { texted: true, dial: "+33199001234".into(), ..porch.clone() };
2193 let sent = |phone: &str, to: &str, _: i64| vec![Mine { key: "t1".into(), time: "14:00".into(), text: "Oui.".into(), said: format!("Sent from {phone} to {to}."), texts: true, ..Mine::default() }];
2194 let this_phone = |phone: &str| phone == "phone";
2195 let o = Offer { live: &BTreeMap::new(), asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &this_phone, shared: &shared, time_of: &time, sent_texts: &sent };
2196 let answer = answer_of(&texted, &[&line], &o).unwrap();
2197 assert!(answer.texts && answer.offered && answer.limit == crate::texts::WRITE_MAX && answer.phone == "phone");
2198 assert_eq!(answer.mine[0].said, "Sent from phone to +33199001234.");
2199 let other_phone = |phone: &str| phone == "other-phone";
2200 let o = Offer { live: &open, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &other_phone, shared: &shared, time_of: &time, sent_texts: &sent };
2201 let answer = answer_of(&texted, &[&line], &o).unwrap();
2202 assert!(!answer.texts && answer.offered, "another phone sends texts: its own Reply, never the other's SIM");
2203 let older = phonemsgs::Line { notice: String::new(), ..line.clone() };
2205 let o = Offer { live: &open, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &all, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2206 assert!(answer_of(&porch, &[&older], &o).is_none());
2207 assert!(answer_of(&phonemsgs::PorchLine { code: true, ..porch.clone() }, &[&line], &o).is_none());
2208 let read = Ask { verb: "read".into(), words: String::new(), ..mine.clone() };
2210 assert!(read_handed(std::slice::from_ref(&read), std::slice::from_ref(&handed), "phone", &line.notice));
2211 assert!(!read_handed(std::slice::from_ref(&mine), std::slice::from_ref(&handed), "phone", &line.notice), "a reply is no Mark as read");
2212 }
2213
2214 #[test]
2218 fn what_the_porch_offers_the_phone_sends() {
2219 let tr = crate::i18n::Translator::new("en");
2220 let line = phonemsgs::Line { device: "phone".into(), ..alice_line() };
2221 let newer = phonemsgs::Line { at: NOW - MINUTE, id: format!("{}-bb22", NOW - MINUTE), notice: "1111222233334444".into(), text: "Tu es où ?".into(), ..line.clone() };
2222 let lines = vec![line.clone(), newer.clone()];
2223 let porch = phonemsgs::PorchLine { ids: vec![line.id.clone()], head: String::new(), said: Vec::new(), note: String::new(), number: String::new(), dial: String::new(), known: true, texted: false, code: false, who: "Alice".into(), answer: None };
2224 let live = |notice: &str, reply: bool, gone: i64| (("phone".to_string(), notice.to_string()), Live { notice: notice.into(), app: line.app.clone(), talk: line.talk.clone(), reply, read: reply, gone, at: NOW, device: "phone".into() });
2225 let (shared, time, none, no_one, nobody) = (|_: &str| Some(NOW), |_: i64| "14:03".to_string(), |_: &str, _: &str, _: i64| Vec::new(), |_: &str| false, BTreeSet::new());
2226 let dir = scratch("porch-to-phone");
2227 mark_ledger(&dir, NOW - 30 * MINUTE).unwrap();
2228 let ledger = open_ledger(&dir, NOW).unwrap();
2229 let (cancelled, unsent, yes) = (BTreeSet::new(), BTreeSet::new(), |_: &str| true);
2230 for (lives, expect) in [(vec![live(&line.notice, true, 0)], line.id.clone()), (vec![live(&line.notice, false, NOW), live(&newer.notice, true, 0)], newer.id.clone())] {
2231 let lives: BTreeMap<_, _> = lives.into_iter().collect();
2232 let o = Offer { live: &lives, asks: &[], outcomes: &[], tr: &tr, taken_back: &nobody, lines: &lines, texts_from: &no_one, shared: &shared, time_of: &time, sent_texts: &none };
2233 let answer = answer_of(&porch, &[&line], &o).unwrap();
2234 assert!(answer.offered);
2235 assert_eq!(answer.line, expect);
2236 let request = Ask { key: "k1".into(), phone: answer.phone.clone(), verb: "reply".into(), notice: answer.notice.clone(), app: answer.app.clone(), line: answer.line.clone(), words: "J'arrive.".into(), written: NOW - MINUTE, device: "desk".into(), ..Ask::default() };
2237 let judge = Judge { here: "phone", now_ms: NOW, ledger: &ledger, lines: &lines, standing: &|_| crate::texts::Standing::Counted, cancelled: &cancelled, answers: &yes, unsent: &unsent, went: &unsent };
2238 assert_eq!(verdict(&request, &judge), Verdict::Send, "{}", answer.notice);
2239 }
2240 let _ = std::fs::remove_dir_all(&dir);
2241 }
2242}