1use crate::backend::load_config;
34use serde_json::{Value, json};
35use sioul_core::appnotes::Posted;
36use sioul_core::calls;
37use sioul_core::config::state_dir;
38use sioul_core::phonemsgs::{self, Extra, Line};
39use sioul_core::replies::{self as rules, Ask, Checked, Done, Judge, Live, Outcome, Taken, Verdict};
40use sioul_core::texts::Claim;
41use sioul_sync::textseal::ReplySeal;
42use std::ffi::c_char;
43use std::path::{Path, PathBuf};
44use std::sync::Mutex;
45
46fn now_ms() -> i64 {
47 jiff::Timestamp::now().as_millisecond()
48}
49
50fn seal() -> Option<ReplySeal> {
52 let (_, vault) = crate::share::vault()?;
53 let (_, key) = vault?;
54 Some(ReplySeal::new(&key))
55}
56
57const STATES: &[&str] = &["handed", "gone", "other", "no-reply", "no-read", "closed", "locked", "activity", "failed"];
59
60#[derive(Debug, Clone)]
67struct Sent {
68 key: String,
69 notice: String,
70 hash: String,
71 talk: String,
72 taken: i64,
73 before: bool,
74}
75
76static CHECKED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new());
78static TAKEN: Mutex<Vec<Sent>> = Mutex::new(Vec::new());
80static HANDED: Mutex<Vec<(Sent, i64)>> = Mutex::new(Vec::new());
82
83fn listener(verb: &str, json: &str) -> Value {
85 let asked: Value = serde_json::from_str(json).unwrap_or_default();
86 let key = asked["key"].as_str().unwrap_or_default();
87 let folder = rules::private_folder();
88 match verb {
89 "due" => {
90 let Some(seal) = seal() else { return json!({ "due": [] }) };
91 let due: Vec<Value> = rules::waiting(&folder, &seal).iter().map(|d| json!({ "key": d.key, "verb": d.verb, "notice": d.notice })).collect();
92 json!({ "due": due })
93 }
94 "check" => check(key, &asked["posted"]),
95 "taken" => taken(key),
96 "result" => result(key, &asked),
97 "removed" => json!({ "shared": removed(asked["package"].as_str().unwrap_or_default(), asked["key"].as_str().unwrap_or_default()) }),
98 _ => Value::Null,
99 }
100}
101
102fn check(key: &str, posted: &Value) -> Value {
105 let gone = json!({ "go": false, "state": "gone" });
106 let Some(seal) = seal() else { return gone };
107 let Some(due) = rules::read_due(&rules::private_folder(), &seal).into_iter().find(|d| d.key == key) else { return gone };
108 let Ok(read) = serde_json::from_value::<Posted>(posted.clone()) else { return gone };
109 let extra = Extra::read(&posted.to_string());
110 let config = load_config();
111 let choices = sioul_core::appnotes::Choices::load(&sioul_core::appnotes::Choices::default_path());
112 let words = sioul_core::words::Words::of(&config);
113 match rules::check(&due, &read, &extra, &choices, &words, sioul_core::reach::region(&config)) {
114 Checked::Go { crossed } => {
115 let before = due.verb == "reply" && rules::shown_already(&read, &extra, &rules::words_hash(&due.words));
117 if let Ok(mut checked) = CHECKED.lock() {
118 checked.retain(|(k, _)| k != key);
119 checked.push((key.to_string(), before));
120 }
121 json!({ "go": true, "crossed": crossed })
122 }
123 Checked::Gone => gone,
124 Checked::Other => json!({ "go": false, "state": "other" }),
125 }
126}
127
128fn taken(key: &str) -> Value {
131 let Some(seal) = seal() else { return json!({ "go": false }) };
132 let now = now_ms();
133 match rules::take(&rules::private_folder(), key, &seal, now) {
134 Ok(Taken::Go(due)) => {
135 let before = CHECKED.lock().ok().and_then(|mut checked| {
136 let at = checked.iter().position(|(k, _)| k == key)?;
137 Some(checked.remove(at).1)
138 });
139 if let Ok(mut taken) = TAKEN.lock() {
140 taken.retain(|s| s.key != key);
141 let talk = if due.verb == "reply" { due.talk.clone() } else { String::new() };
142 taken.push(Sent { key: key.to_string(), notice: due.notice.clone(), hash: rules::words_hash(&due.words), talk, taken: now, before: before.unwrap_or(true) });
143 }
144 json!({ "go": true, "words": due.words })
145 }
146 Ok(Taken::Late) => {
147 eprintln!("sioul: replies: one came to the listener too late, not sent");
148 json!({ "go": false, "said": true })
149 }
150 Ok(Taken::No) => json!({ "go": false }),
151 Err(e) => {
152 eprintln!("sioul: replies: {e}");
154 json!({ "go": false })
155 }
156 }
157}
158
159fn result(key: &str, asked: &Value) -> Value {
162 let state = asked["state"].as_str().filter(|s| STATES.contains(s)).unwrap_or("failed");
163 let now = now_ms();
164 let done = Done { crossed: asked["crossed"] == true, held: asked["held"] == true, ..Done::of(key, state, now) };
165 if let Err(e) = rules::say(&rules::private_folder(), &done) {
166 eprintln!("sioul: replies: {e}");
167 }
168 let taken = TAKEN.lock().ok().and_then(|mut taken| {
169 let at = taken.iter().position(|s| s.key == key)?;
170 Some(taken.remove(at))
171 });
172 if state == "handed"
173 && let Some(sent) = taken
174 {
175 if let Err(e) = rules::note_answered(&rules::private_folder(), &sent.talk, now) {
177 eprintln!("sioul: replies: {e}");
178 }
179 if let Ok(mut handed) = HANDED.lock() {
180 handed.retain(|h| now - h.1 <= rules::SHOWN_WITHIN_MS);
181 handed.push((sent, now));
182 }
183 }
184 json!({ "said": true })
185}
186
187pub(crate) fn heard(json: &str, posted: &Posted) -> bool {
191 let Ok(mut handed) = HANDED.lock() else { return false };
192 if handed.is_empty() || posted.key.is_empty() {
193 return false;
194 }
195 let now = now_ms();
196 handed.retain(|h| now - h.1 <= rules::SHOWN_WITHIN_MS);
197 let notice = rules::notice(&posted.package, &posted.key);
198 let extra = Extra::read(json);
199 let shown: Vec<String> = handed.iter().filter(|(s, _)| s.notice == notice && rules::shows(posted, &extra, &s.hash, s.taken, s.before)).map(|(s, _)| s.key.clone()).collect();
200 handed.retain(|(s, _)| !shown.contains(&s.key));
201 drop(handed);
202 for key in &shown {
203 if let Err(e) = rules::say(&rules::private_folder(), &Done::of(key, "shown", now)) {
204 eprintln!("sioul: replies: {e}");
205 }
206 }
207 !shown.is_empty()
208}
209
210fn own_live() -> Option<std::path::PathBuf> {
213 let here = sioul_sync::share::Here::load(&state_dir());
214 (!here.id.is_empty() && here.folder.is_some() && here.shares(phonemsgs::PART, &load_config())).then(|| rules::live_file(&phonemsgs::folder(), &here.id))
215}
216
217pub(crate) fn offered(device: &str, posted: &Posted, lines: &[Line], extra: &Extra, answers: bool, now_ms: i64) -> bool {
221 let Some(first) = lines.first().filter(|l| !l.notice.is_empty()) else { return false };
222 let live = Live { notice: first.notice.clone(), app: posted.package.clone(), talk: first.talk.clone(), reply: answers && extra.offer.reply, read: answers && extra.offer.read, at: now_ms, ..Live::default() };
223 match rules::offer(&rules::live_file(&phonemsgs::folder(), device), &live) {
224 Ok(written) => written,
225 Err(e) => {
226 eprintln!("sioul: replies: {e}");
227 false
228 }
229 }
230}
231
232pub(crate) fn relayed(now_ms: i64) {
236 if let Err(e) = rules::mark_ledger(&rules::private_folder(), now_ms) {
237 eprintln!("sioul: replies: {e}");
238 }
239}
240
241fn removed(package: &str, key: &str) -> bool {
244 if package.is_empty() || key.is_empty() {
245 return false;
246 }
247 let Some(own) = own_live() else { return false };
248 match rules::went(&own, &rules::notice(package, key), now_ms()) {
249 Ok(written) => written,
250 Err(e) => {
251 eprintln!("sioul: replies: {e}");
252 false
253 }
254 }
255}
256
257fn phone() -> bool {
261 cfg!(target_os = "android") || std::env::var_os("SIOUL_PHONE_MESSAGES").is_some()
262}
263
264fn sharing() -> Option<(String, PathBuf, [u8; 32])> {
267 let (here, vault) = crate::share::vault()?;
268 let (folder, key) = vault?;
269 crate::share::shares(phonemsgs::PART).then_some((here, folder, key))
270}
271
272static TRIMMED: Mutex<i64> = Mutex::new(0);
274
275pub(crate) fn before_exchange() {
280 let Some((here, folder, key)) = sharing() else { return };
281 let now = now_ms();
282 if phone() {
283 step(&here, &folder, &key, now);
284 }
285 let due = TRIMMED.lock().map(|mut last| {
286 let due = now - *last > 86_400_000;
287 if due {
288 *last = now;
289 }
290 due
291 });
292 if due.unwrap_or(false) {
293 let seal = ReplySeal::new(&key);
294 let trimmed = rules::trim_shared(&phonemsgs::folder(), &here, &seal, now).and_then(|_| if phone() { rules::trim_private(&rules::private_folder(), &seal, now) } else { Ok(()) });
295 if let Err(e) = trimmed {
296 eprintln!("sioul: replies: {e}");
297 }
298 }
299}
300
301pub(crate) fn phone_step() -> bool {
306 if !phone() {
307 return false;
308 }
309 let Some((here, folder, key)) = sharing() else { return false };
310 step(&here, &folder, &key, now_ms())
311}
312
313fn step(here: &str, folder: &Path, key: &[u8; 32], now: i64) -> bool {
318 let seal = ReplySeal::new(key);
319 let root = phonemsgs::folder();
320 let roots: Vec<PathBuf> = std::iter::once(root.clone()).chain(crate::share::replies_copies()).collect();
321 let own = calls::own_file(&root, rules::OUTCOME, here);
322 let private = rules::private_folder();
323 if let Err(e) = rules::mark_ledger(&private, now) {
325 eprintln!("sioul: replies: {e}");
326 }
327 let marks = rules::read_marks(&private);
329 let (asked, reads) = rules::read_asks_after(&roots, &seal, &marks);
330 let decided = asked.0.iter().any(|a| a.phone == here) && {
331 let standing = crate::texts::standing_of(folder, key, here, now);
332 decide(here, &asked, &root, &private, &seal, &standing, &own, now)
333 };
334 remember_reads(here, &private, &reads, &marks, now);
335 let settled = settle(&private, &own, now);
336 decided || settled
337}
338
339fn remember_reads(here: &str, private: &Path, reads: &[rules::FileRead], marks: &std::collections::BTreeMap<String, rules::ReadMark>, now: i64) {
342 let Ok(ledger) = rules::open_ledger(private, now) else { return };
343 let moved = rules::moved_marks(reads, &|key: &str, phone: &str| phone != here || ledger.has(key), marks);
344 if moved != *marks
345 && let Err(e) = rules::save_read_marks(private, &moved)
346 {
347 eprintln!("sioul: replies: {e}");
348 }
349}
350
351#[allow(clippy::too_many_arguments)]
360fn decide(here: &str, asked: &(Vec<Ask>, std::collections::BTreeSet<(String, String)>), root: &Path, private: &Path, seal: &ReplySeal, standing: &dyn Fn(&str) -> sioul_core::texts::Standing, own: &Path, now: i64) -> bool {
361 let (asks, cancelled) = asked;
362 if asks.iter().all(|a| a.phone != here) {
363 return false;
364 }
365 let lines = phonemsgs::read_file(&calls::own_file(root, phonemsgs::LOG, here));
366 let (unsent, went) = (rules::unsent(private), rules::went_keys(private));
368 let ledger_path = private.join(sioul_core::texts::LEDGER);
369 let decided = sioul_core::filelock::with_lock(&ledger_path, || -> Result<(Vec<rules::Due>, Vec<Outcome>), String> {
370 let mut ledger = rules::open_ledger(private, now)?;
371 let choices = phonemsgs::Choices::load(&phonemsgs::Choices::default_path());
373 let answers = |app: &str| choices.answers(app);
374 let (mut lines_out, mut due, mut said) = (Vec::new(), Vec::new(), Vec::new());
375 for ask in rules::in_turn(asks, here) {
376 if lines_out.len() >= rules::STEP_MAX {
377 break;
378 }
379 let judge = Judge { here, now_ms: now, ledger: &ledger, lines: &lines, standing, cancelled, answers: &answers, unsent: &unsent, went: &went };
380 let (state, reason) = match rules::verdict(ask, &judge) {
381 Verdict::NotMine | Verdict::Decided | Verdict::Wait => continue,
383 Verdict::Send => {
384 let talk = lines.iter().find(|l| l.id == ask.line).map_or("", |l| l.talk.as_str());
386 let claim = rules::claim_of(ask, talk, now);
387 ledger.add(claim.clone());
389 lines_out.push(claim);
390 if let Some(line) = lines.iter().find(|l| l.id == ask.line) {
391 due.push(rules::due_of(ask, line, now));
392 }
393 continue;
394 }
395 Verdict::TakenBack => ("taken-back", ""),
396 Verdict::Expired => ("expired", ""),
397 Verdict::Clocks => ("clocks", ""),
398 Verdict::Refused(why) => ("refused", why),
399 };
400 let end = rules::end_of(&ask.key, state, reason, now);
401 ledger.add(end.clone());
402 lines_out.push(end);
403 said.push(Outcome { key: ask.key.clone(), state: state.into(), at: now, reason: reason.into(), ..Outcome::default() });
404 }
405 sioul_core::texts::record_all(private, &lines_out)?;
407 Ok((due, said))
408 });
409 match decided {
410 Ok((due, said)) => {
411 if !due.is_empty() {
412 match rules::add_due(private, &due, seal, now) {
414 Ok(()) => eprintln!("sioul: replies: {} handed to the listener", due.len()),
415 Err(e) => eprintln!("sioul: replies: {e}"),
416 }
417 }
418 match rules::say_outcomes(own, &said) {
419 Ok(written) => written > 0,
420 Err(e) => {
421 eprintln!("sioul: replies: {e}");
423 false
424 }
425 }
426 }
427 Err(e) => {
428 eprintln!("sioul: replies: {e}");
430 false
431 }
432 }
433}
434
435fn settle(private: &Path, own: &Path, now: i64) -> bool {
442 if !private.join(sioul_core::texts::LEDGER).is_file() {
443 return false;
444 }
445 let Ok(ledger) = rules::open_ledger(private, now) else { return false };
446 let mut last: std::collections::BTreeMap<&str, &Claim> = std::collections::BTreeMap::new();
447 for claim in &ledger.claims {
448 last.insert(claim.key.as_str(), claim);
449 }
450 let claimed: Vec<(String, i64)> = last.values().filter(|c| c.state == "claimed").map(|c| (c.key.clone(), c.at)).collect();
451 let done = match rules::settle(private, &claimed, now) {
452 Ok(done) => done,
453 Err(e) => {
454 eprintln!("sioul: replies: {e}");
455 return false;
456 }
457 };
458 let mut outcomes = rules::outcomes_of(&done);
459 outcomes.extend(rules::ends_of(&ledger, now));
460 let written = rules::say_outcomes(own, &outcomes).unwrap_or_else(|e| {
461 eprintln!("sioul: replies: {e}");
462 0
463 });
464 let ended: std::collections::BTreeSet<&str> = done.iter().filter(|d| d.state != "taken").map(|d| d.key.as_str()).collect();
466 let ends: Vec<Claim> = claimed.iter().filter(|(key, _)| ended.contains(key.as_str())).map(|(key, _)| Claim { key: key.clone(), at: now, state: "done".into(), ..Claim::default() }).collect();
467 if let Err(e) = sioul_core::texts::record_all(private, &ends) {
468 eprintln!("sioul: replies: {e}");
469 }
470 written > 0
471}
472
473pub(crate) fn ask(phone: &str, verb: &str, notice: &str, app: &str, line: &str, words: &str, again: &str) -> Result<String, String> {
479 let tr = crate::backend::tr();
480 let Some((here, _, key)) = sharing() else { return Err(tr.text("replies-refused", None)) };
481 let words = if verb == "reply" { words.trim().to_string() } else { String::new() };
482 if verb == "reply" && words.is_empty() {
483 return Err(tr.text("replies-refused-no-words", None));
484 }
485 if words.chars().count() > rules::WORDS_MAX {
486 return Err(tr.text("replies-refused-too-long", None));
487 }
488 let ask = Ask { key: sioul_core::texts::new_key(), phone: phone.to_string(), verb: verb.to_string(), notice: notice.to_string(), app: app.to_string(), line: line.to_string(), words, written: now_ms(), ahead: crate::texts::ahead_of(phone), again: again.to_string(), ..Ask::default() };
489 rules::write_ask(&phonemsgs::folder(), &here, &ask, &ReplySeal::new(&key))?;
490 crate::share::replies_now();
491 Ok(ask.key)
492}
493
494pub(crate) fn take_back(key: &str) -> Result<(), String> {
498 let Some((here, _, sharing_key)) = sharing() else { return Ok(()) };
499 let seal = ReplySeal::new(&sharing_key);
500 let root = phonemsgs::folder();
501 let (asks, _) = rules::read_asks(std::slice::from_ref(&root), &seal);
502 let Some(asked) = asks.into_iter().find(|a| a.key == key && a.device == here) else { return Ok(()) };
503 let cancel = Ask { verb: "cancel".into(), words: String::new(), written: now_ms(), ahead: crate::texts::ahead_of(&asked.phone), again: String::new(), device: String::new(), ..asked };
504 rules::write_ask(&root, &here, &cancel, &seal)?;
505 crate::share::replies_now();
506 Ok(())
507}
508
509pub(crate) fn answered_lately(talk: &str) -> bool {
513 rules::answered_lately(&rules::private_folder(), talk, now_ms())
514}
515
516pub(crate) fn texted(number: &str) {
520 if let Err(e) = rules::note_answered(&rules::private_folder(), &rules::answered_number(number), now_ms()) {
521 eprintln!("sioul: replies: {e}");
522 }
523}
524
525pub(crate) fn texted_lately(numbers: &[String]) -> bool {
527 let (folder, now) = (rules::private_folder(), now_ms());
528 numbers.iter().any(|n| rules::answered_lately(&folder, &rules::answered_number(n), now))
529}
530
531pub(crate) fn answered_here(all: &[Line], now: &jiff::Zoned) -> rules::Answered {
534 let Some((here, _, key)) = sharing() else { return rules::Answered::default() };
535 let root = phonemsgs::folder();
536 let now_ms = now.timestamp().as_millisecond();
537 let own: Vec<Ask> = rules::read_asks(std::slice::from_ref(&root), &ReplySeal::new(&key)).0.into_iter().filter(|a| a.device == here).collect();
538 let mut answered = rules::Answered::of(&own, &rules::read_outcomes(&root), all, now_ms);
539 if all.iter().any(|l| l.kind == "text") {
541 for number in crate::texts::texted_since(now_ms - rules::ANSWERED_MS) {
542 answered.texted(number);
543 }
544 }
545 answered
546}
547
548pub(crate) fn fill(lines: &mut Vec<sioul_core::phonemsgs::PorchLine>, all: &[Line], now: &jiff::Zoned) {
554 let Some((here, _, key)) = sharing() else { return };
555 let root = phonemsgs::folder();
556 let now_ms = now.timestamp().as_millisecond();
557 let live = rules::read_live(&root, now_ms);
558 let seal = ReplySeal::new(&key);
559 let (asks, cancels) = rules::read_asks(std::slice::from_ref(&root), &seal);
560 let own: Vec<Ask> = asks.into_iter().filter(|a| a.device == here).collect();
561 let taken_back: std::collections::BTreeSet<String> = cancels.into_iter().filter(|(device, _)| *device == here).map(|(_, key)| key).collect();
563 let outcomes = rules::read_outcomes(&root);
564 let phones = crate::texts::phones();
565 let shared = |device: &str| phones.iter().find(|p| p.id == device).filter(|p| p.exported > 0).map(|p| p.exported * 1000);
566 let time_of = |ms: i64| sioul_core::texts::time_of_day(ms, now);
567 let sent = |phone: &str, dial: &str, since: i64| crate::texts::sent_to(phone, dial, since);
568 let texts_from = |phone: &str| crate::texts::sends_texts(phone);
569 let offer = rules::Offer { live: &live, asks: &own, outcomes: &outcomes, tr: crate::backend::tr(), taken_back: &taken_back, lines: all, texts_from: &texts_from, shared: &shared, time_of: &time_of, sent_texts: &sent };
570 let by_id: std::collections::BTreeMap<&str, &Line> = all.iter().map(|l| (l.id.as_str(), l)).collect();
571 let mut seen: Vec<String> = Vec::new();
572 for line in lines.iter_mut() {
573 let group: Vec<&Line> = line.ids.iter().filter_map(|id| by_id.get(id.as_str()).copied()).collect();
574 line.answer = rules::answer_of(line, &group, &offer);
575 if line.answer.as_ref().is_some_and(|a| rules::read_handed(&own, &outcomes, &a.phone, &a.notice)) {
576 seen.extend(line.ids.iter().cloned());
577 }
578 }
579 if !seen.is_empty() {
580 if let Err(e) = calls::mark_seen(&root, &here, &seen, now_ms) {
581 eprintln!("sioul: replies: {e}");
582 }
583 lines.retain(|l| !l.ids.iter().any(|id| seen.contains(id)));
584 }
585}
586
587#[unsafe(no_mangle)]
594pub unsafe extern "C" fn sioul_replies_listener(verb: *const c_char, json: *const c_char) -> *mut c_char {
595 let (verb, json) = unsafe { (crate::alarms::key_of(verb), crate::alarms::key_of(json)) };
597 let answer = std::panic::catch_unwind(|| listener(&verb, &json).to_string()).unwrap_or_else(|_| "null".to_string());
598 crate::alarms::handed(answer)
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
609 fn a_relayed_notification_says_what_it_offers() {
610 let device = format!("phone-{}", std::process::id());
611 let posted = Posted { key: "0|org.thoughtcrime.securesms|42|null|10123".into(), package: "org.thoughtcrime.securesms".into(), ..Posted::default() };
612 let line = Line { at: 1, id: "1-a".into(), notice: rules::notice(&posted.package, &posted.key), talk: "fedcba9876543210".into(), ..Line::default() };
613 let extra = Extra { offer: phonemsgs::Offer { reply: true, read: false }, ..Extra::default() };
614 let now = now_ms();
615 assert!(offered(&device, &posted, std::slice::from_ref(&line), &extra, true, now));
616 assert!(!offered(&device, &posted, std::slice::from_ref(&line), &extra, true, now + 1_000), "the same offer");
617 assert!(!offered(&device, &posted, &[Line { notice: String::new(), ..line.clone() }], &extra, true, now + 2_000), "an older line, no name");
618 assert!(!offered(&device, &posted, &[], &extra, true, now + 3_000));
619 let live = rules::read_live(&phonemsgs::folder(), now + 4_000);
620 let said = live.get(&(device.clone(), line.notice.clone())).unwrap();
621 assert_eq!((said.reply, said.read, said.talk.as_str(), said.app.as_str()), (true, false, "fedcba9876543210", "org.thoughtcrime.securesms"));
622 assert!(offered(&device, &posted, std::slice::from_ref(&line), &extra, false, now + 5_000));
624 assert!(!rules::read_live(&phonemsgs::folder(), now + 6_000).get(&(device.clone(), line.notice.clone())).unwrap().reply);
625 let _ = std::fs::remove_file(rules::live_file(&phonemsgs::folder(), &device));
626 }
627
628 #[test]
635 fn the_phone_decides_each_request_once_and_says_what_became_of_it() {
636 let base = std::env::temp_dir().join(format!("sioul-app-replies-step-{}", std::process::id()));
637 let _ = std::fs::remove_dir_all(&base);
638 let (root, private) = (base.join("phone-messages"), base.join("private"));
639 let seal = ReplySeal::new(&[9u8; 32]);
640 let now = now_ms();
641 let line = Line { at: now - 300_000, id: format!("{}-aa", now - 300_000), app: "org.thoughtcrime.securesms".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Tu passes ?".into(), shows: now - 300_000, ..Line::default() };
642 phonemsgs::carry_into(&calls::own_file(&root, phonemsgs::LOG, "phone"), std::slice::from_ref(&line), now).unwrap();
643 rules::mark_ledger(&private, now - 60_000).unwrap();
645 let ask = |key: &str, device: &str| (device.to_string(), Ask { key: key.into(), phone: "phone".into(), verb: "reply".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), words: format!("Réponse {key}."), written: now, ..Ask::default() });
646 for (device, a) in [ask("k1", "desk"), ask("k2", "desk"), ask("k3", "laptop"), ask("k4", "desk")] {
647 rules::write_ask(&root, &device, &a, &seal).unwrap();
648 }
649 rules::write_ask(&root, "desk", &Ask { verb: "cancel".into(), words: String::new(), ..ask("k2", "desk").1 }, &seal).unwrap();
650 let own = calls::own_file(&root, rules::OUTCOME, "phone");
651 let counted = |device: &str| if device == "desk" { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone };
652 let roots = vec![root.clone()];
653 let asked = || rules::read_asks(&roots, &seal);
654 assert!(decide("phone", &asked(), &root, &private, &seal, &counted, &own, now), "taken back and not counted, said");
655 let due = rules::read_due(&private, &seal);
656 assert_eq!(due.iter().map(|d| (d.key.as_str(), d.words.as_str(), d.talk.as_str())).collect::<Vec<_>>(), [("k1", "Réponse k1.", "fedcba9876543210"), ("k4", "Réponse k4.", "fedcba9876543210")]);
657 let states = |o: &[Outcome]| {
659 let mut said = o.iter().map(|o| (o.key.clone(), o.state.clone(), o.reason.clone())).collect::<Vec<_>>();
660 said.sort();
661 said
662 };
663 let said = rules::read_outcomes(&root);
664 assert_eq!(states(&said), [("k2".to_string(), "taken-back".to_string(), String::new()), ("k3".into(), "refused".into(), "not-counted".into())]);
665 assert!(!decide("phone", &asked(), &root, &private, &seal, &counted, &own, now + 1_000));
667 assert_eq!(rules::read_due(&private, &seal).len(), 2);
668 assert!(matches!(rules::take(&private, "k1", &seal, now + 2_000).unwrap(), rules::Taken::Go(_)));
670 rules::say(&private, &Done::of("k1", "handed", now + 3_000)).unwrap();
671 assert!(settle(&private, &own, now + 4_000));
672 assert!(rules::read_outcomes(&root).iter().any(|o| o.key == "k1" && o.state == "handed"));
673 assert!(settle(&private, &own, now + 3 * 60_000), "k4, never taken: late");
674 let all = rules::read_outcomes(&root);
675 assert!(all.iter().any(|o| o.key == "k4" && o.state == "late"), "{all:?}");
676 assert!(!settle(&private, &own, now + 4 * 60_000), "each once; the claims ended");
677 let ledger = rules::open_ledger(&private, now).unwrap();
678 assert_eq!((ledger.state("k1"), ledger.state("k4"), ledger.state("k3")), (Some("done"), Some("done"), Some("refused:not-counted")));
679 let text = std::fs::read_to_string(&own).unwrap();
680 assert!(!text.contains("Réponse") && !text.contains("Alice"), "no words, no names");
681 std::fs::remove_file(&own).unwrap();
683 assert!(settle(&private, &own, now + 5 * 60_000));
684 let again = rules::read_outcomes(&root);
685 assert!(again.iter().any(|o| o.key == "k3" && o.state == "refused" && o.reason == "not-counted"), "{again:?}");
686 assert!(!settle(&private, &own, now + 6 * 60_000), "once");
687 let _ = std::fs::remove_dir_all(&base);
688 }
689
690 #[test]
697 fn the_first_reply_after_install_goes_and_one_gone_can_go_again() {
698 let base = std::env::temp_dir().join(format!("sioul-app-replies-first-{}", std::process::id()));
699 let _ = std::fs::remove_dir_all(&base);
700 let (root, private) = (base.join("phone-messages"), base.join("private"));
701 let seal = ReplySeal::new(&[9u8; 32]);
702 let t0 = now_ms() - 10 * 60_000;
703 let line = Line { at: t0 - 60_000, id: format!("{}-aa", t0 - 60_000), app: "org.thoughtcrime.securesms".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Tu passes ?".into(), shows: t0, ..Line::default() };
704 assert!(!private.exists(), "an empty private folder");
705 rules::mark_ledger(&private, t0).unwrap();
706 phonemsgs::carry_into(&calls::own_file(&root, phonemsgs::LOG, "phone"), std::slice::from_ref(&line), t0).unwrap();
707 let ask = |key: &str, at: i64| Ask { key: key.into(), phone: "phone".into(), verb: "reply".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), words: "J'arrive.".into(), written: at, ..Ask::default() };
708 rules::write_ask(&root, "desk", &ask("k1", t0 + 90_000), &seal).unwrap();
709 let own = calls::own_file(&root, rules::OUTCOME, "phone");
710 let counted = |device: &str| if device == "desk" { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone };
711 let roots = vec![root.clone()];
712 let step = |at: i64| {
714 rules::mark_ledger(&private, at).unwrap();
715 let decided = decide("phone", &rules::read_asks(&roots, &seal), &root, &private, &seal, &counted, &own, at);
716 settle(&private, &own, at) || decided
717 };
718 step(t0 + 180_000);
719 assert_eq!(rules::read_due(&private, &seal).iter().map(|d| d.key.as_str()).collect::<Vec<_>>(), ["k1"], "sent: {:?}", rules::read_outcomes(&root));
720 assert!(matches!(rules::take(&private, "k1", &seal, t0 + 200_000).unwrap(), rules::Taken::Go(_)));
721 rules::say(&private, &Done::of("k1", "gone", t0 + 201_000)).unwrap();
722 step(t0 + 240_000);
723 assert!(rules::read_outcomes(&root).iter().any(|o| o.key == "k1" && o.state == "gone"));
724 rules::write_ask(&root, "desk", &ask("k2", t0 + 300_000), &seal).unwrap();
726 step(t0 + 360_000);
727 let said = rules::read_outcomes(&root);
728 assert!(!said.iter().any(|o| o.key == "k2"), "{said:?}");
729 assert!(rules::read_due(&private, &seal).iter().any(|d| d.key == "k2"));
730 let _ = std::fs::remove_dir_all(&base);
731 }
732
733 fn copy_all(from: &Path, to: &Path) {
734 std::fs::create_dir_all(to).unwrap();
735 for entry in std::fs::read_dir(from).unwrap().flatten() {
736 let path = entry.path();
737 if path.is_dir() {
738 copy_all(&path, &to.join(entry.file_name()));
739 } else {
740 std::fs::copy(&path, to.join(entry.file_name())).unwrap();
741 }
742 }
743 }
744
745 #[test]
751 #[ignore]
752 fn a_dose_step_with_a_runaway_backlog_stays_within_tens_of_milliseconds() {
753 let base = std::env::temp_dir().join(format!("sioul-app-replies-dose-{}", std::process::id()));
754 let _ = std::fs::remove_dir_all(&base);
755 let template = base.join("template");
756 let (root, private) = (template.join("phone-messages"), template.join("private"));
757 let seal = ReplySeal::new(&[9u8; 32]);
758 let now = now_ms();
759 rules::mark_ledger(&private, now - 86_400_000).unwrap();
760 let line = Line { at: now - 600_000, id: format!("{}-aa", now - 600_000), app: "org.thoughtcrime.securesms".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Tu passes ?".into(), shows: now - 600_000, ..Line::default() };
761 phonemsgs::carry_into(&calls::own_file(&root, phonemsgs::LOG, "phone"), std::slice::from_ref(&line), now).unwrap();
762 let mut file = String::new();
764 let mut ends = Vec::new();
765 for i in 0..10_000 {
766 let key = format!("{i:032x}");
767 let ask = Ask { key: key.clone(), phone: "phone".into(), verb: "read".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), written: now - 20 * 60_000, writer: "runaway".into(), ..Ask::default() };
768 file.push_str(&sioul_core::texts::Sealer::seal(&seal, &rules::padded_ask(&ask)));
769 file.push('\n');
770 ends.push(rules::end_of(&key, "expired", "", now - 10 * 60_000));
771 }
772 std::fs::create_dir_all(root.join(rules::ASK)).unwrap();
773 std::fs::write(calls::own_file(&root, rules::ASK, "runaway"), file).unwrap();
774 let said: Vec<Outcome> = ends.iter().map(|c| Outcome { key: c.key.clone(), state: "expired".into(), at: c.at, ..Outcome::default() }).collect();
776 rules::say_outcomes(&calls::own_file(&root, rules::OUTCOME, "phone"), &said).unwrap();
777 ends.extend((0..150).map(|i| Claim { key: format!("c{i:031x}"), at: now - 3 * 60_000, state: "claimed".into(), to: line.notice.clone(), ..Claim::default() }));
779 sioul_core::texts::record_all(&private, &ends).unwrap();
780 let counted = |device: &str| if device == "runaway" { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone };
781 let mut times = Vec::new();
782 for run in 0..7 {
783 let at = base.join(format!("run-{run}"));
784 copy_all(&template, &at);
785 let (root, private) = (at.join("phone-messages"), at.join("private"));
786 let own = calls::own_file(&root, rules::OUTCOME, "phone");
787 let (_, reads) = rules::read_asks_after(std::slice::from_ref(&root), &seal, &std::collections::BTreeMap::new());
789 remember_reads("phone", &private, &reads, &std::collections::BTreeMap::new(), now);
790 let started = std::time::Instant::now();
791 rules::mark_ledger(&private, now).unwrap();
792 let marks = rules::read_marks(&private);
793 let (asked, reads) = rules::read_asks_after(std::slice::from_ref(&root), &seal, &marks);
794 let read = started.elapsed();
795 let decided = asked.0.iter().any(|a| a.phone == "phone") && decide("phone", &asked, &root, &private, &seal, &counted, &own, now);
796 remember_reads("phone", &private, &reads, &marks, now);
797 let settled = settle(&private, &own, now);
798 let took = started.elapsed();
799 assert!(!decided && settled, "nothing new decided; the 150 said late");
800 assert!(asked.0.is_empty(), "the 10,000 decided never opened again");
801 times.push((took.as_secs_f64() * 1000.0, read.as_secs_f64() * 1000.0));
802 let late = rules::read_outcomes(&root).iter().filter(|o| o.state == "late").count();
803 assert_eq!(late, 150);
804 }
805 times.sort_by(|a, b| a.0.total_cmp(&b.0));
806 let (median, max) = (times[times.len() / 2], times[times.len() - 1]);
807 println!("a dose's step, 10,000 requests decided and 150 claims never taken: {:.1} ms median (reading the requests {:.1} ms), {:.1} ms at most", median.0, median.1, max.0);
808 assert!(median.0 < 1_000.0, "{times:?}");
809 let _ = std::fs::remove_dir_all(&base);
810 }
811}