1use crate::backend::{QtThread, load_config, tr};
29use cxx_qt_lib::QString;
30use jiff::Zoned;
31use serde::{Deserialize, Serialize};
32use serde_json::{Value, json};
33use sioul_core::config::{Config, config_dir, state_dir};
34use sioul_core::phones;
35use sioul_core::porch::Senders;
36use sioul_core::texts::{self as rules, Claim, Outcome, Part, Request, Text, Verdict};
37use sioul_sync::textseal::TextSeal;
38use std::collections::{BTreeMap, BTreeSet};
39use std::path::{Path, PathBuf};
40use std::sync::Mutex;
41use std::sync::atomic::{AtomicBool, Ordering};
42
43fn phone() -> bool {
45 cfg!(target_os = "android") || std::env::var_os("SIOUL_TEXTS").is_some()
46}
47
48fn demo() -> bool {
52 std::env::var_os("SIOUL_DEMO").is_some()
53}
54
55enum Seal {
57 Key(TextSeal),
58 Demo,
59}
60
61impl rules::Sealer for Seal {
62 fn seal(&self, plain: &str) -> String {
63 match self {
64 Seal::Key(seal) => seal.seal(plain),
65 Seal::Demo => format!("demo:{plain}"),
66 }
67 }
68
69 fn open(&self, sealed: &str) -> Option<String> {
70 match self {
71 Seal::Key(seal) => seal.open(sealed),
72 Seal::Demo => sealed.strip_prefix("demo:").map(str::to_string),
73 }
74 }
75}
76
77impl Seal {
78 fn seal_bytes(&self, plain: &[u8]) -> Vec<u8> {
79 match self {
80 Seal::Key(seal) => seal.seal_bytes(plain),
81 Seal::Demo => plain.to_vec(),
82 }
83 }
84
85 fn open_bytes(&self, sealed: &[u8]) -> Option<Vec<u8>> {
86 match self {
87 Seal::Key(seal) => seal.open_bytes(sealed),
88 Seal::Demo => Some(sealed.to_vec()),
89 }
90 }
91}
92
93type Vault = (PathBuf, [u8; 32]);
95
96fn sealed() -> Option<(String, Seal, Option<Vault>)> {
99 if demo() && !phone() {
100 return Some(("demo-desk".into(), Seal::Demo, None));
101 }
102 let (here, vault) = crate::share::vault()?;
103 let (folder, key) = vault?;
104 crate::share::shares(rules::PART).then(|| (here, Seal::Key(TextSeal::new(&key)), Some((folder, key))))
105}
106
107fn root() -> PathBuf {
108 rules::folder()
109}
110
111fn private() -> PathBuf {
112 rules::private_folder()
113}
114
115fn now_ms() -> i64 {
116 jiff::Timestamp::now().as_millisecond()
117}
118
119fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), String> {
121 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
122 if let Some(dir) = path.parent() {
123 std::fs::create_dir_all(dir).map_err(fail)?;
124 }
125 let fresh = path.with_extension(format!("{}.new", std::process::id()));
126 std::fs::write(&fresh, bytes).map_err(fail)?;
127 std::fs::rename(&fresh, path).map_err(|e| {
128 let _ = std::fs::remove_file(&fresh);
129 fail(e)
130 })
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139struct Choice {
140 #[serde(default, skip_serializing_if = "String::is_empty")]
141 phone: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 media_cap_mb: Option<u64>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
148 mms_kb: Option<u64>,
149}
150
151fn choice_path() -> PathBuf {
152 config_dir().join("texts.toml")
153}
154
155fn choice() -> Choice {
156 std::fs::read_to_string(choice_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
157}
158
159const CAPS_MB: [u64; 5] = [1, 5, 10, 25, 50];
161
162fn cap_mb() -> u64 {
164 choice().media_cap_mb.unwrap_or(rules::MEDIA_CAP_MB).clamp(1, sioul_sync::blobs::LARGEST >> 20)
165}
166
167trait Android {
172 fn ask(&self, verb: &str, asked: &Value) -> Value;
173}
174
175struct Phone;
177
178impl Android for Phone {
179 fn ask(&self, verb: &str, asked: &Value) -> Value {
180 crate::steps::java(verb, &asked.to_string())
181 }
182}
183
184struct Files {
186 log: PathBuf,
187 reader: PathBuf,
188 incoming: PathBuf,
189}
190
191impl Files {
192 fn of(here: &str) -> Files {
194 Files { log: rules::own_file(&root(), rules::LOG, here), reader: Reader::path(), incoming: private().join("incoming") }
195 }
196}
197
198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(default)]
204struct Rows {
205 sms: Vec<[i64; 2]>,
206 mms: Vec<[i64; 2]>,
207}
208
209impl Rows {
210 fn of(rows: impl IntoIterator<Item = rules::Row>) -> Rows {
211 let mut out = Rows::default();
212 for row in rows {
213 if row.mms {
214 out.mms.push([row.id, row.date]);
215 } else {
216 out.sms.push([row.id, row.date]);
217 }
218 }
219 out.sms.sort_unstable();
220 out.sms.dedup();
221 out.mms.sort_unstable();
222 out.mms.dedup();
223 out
224 }
225
226 fn set(&self) -> BTreeSet<rules::Row> {
227 let rows = |mms: bool, list: &[[i64; 2]]| list.iter().map(|[id, date]| rules::Row { mms, id: *id, date: *date }).collect::<Vec<_>>();
228 rows(false, &self.sms).into_iter().chain(rows(true, &self.mms)).collect()
229 }
230
231 fn len(&self) -> usize {
232 self.sms.len() + self.mms.len()
233 }
234
235 fn is_empty(&self) -> bool {
236 self.len() == 0
237 }
238}
239
240#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
245#[serde(default)]
246struct Reader {
247 version: u32,
254 logged: Rows,
258 indexed: bool,
259 arrived: Rows,
262 backlog: Rows,
266 newest_at: i64,
268 since: i64,
272 started: i64,
273 total_texts: u64,
275 total_media: u64,
276 measured: i64,
277 done_texts: u64,
279 done_media: u64,
280 looked: i64,
282 behind: bool,
285 left: bool,
288}
289
290const READER_VERSION: u32 = 4;
295const MMS_SHARE: usize = 100;
297const DAY_MS: i64 = 86_400_000;
299
300impl Reader {
301 fn path() -> PathBuf {
302 private().join(rules::READER)
303 }
304
305 fn load() -> Reader {
306 Reader::load_at(&Reader::path())
307 }
308
309 fn load_at(path: &Path) -> Reader {
310 let fresh = || Reader { version: READER_VERSION, ..Reader::default() };
311 match std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Reader>(&t).ok()) {
312 Some(reader) if reader.version == READER_VERSION => reader,
313 Some(reader) if reader.version == 2 || reader.version == 3 => Reader { version: READER_VERSION, indexed: false, logged: Rows::default(), arrived: Rows::default(), backlog: Rows::default(), ..reader },
316 Some(_) => {
317 if let Some(dir) = path.parent() {
319 for old in ["log", "send", "outcome", "ask", "answer"] {
320 let _ = std::fs::remove_dir_all(dir.join(old));
321 }
322 }
323 eprintln!("sioul: texts: the reader starts again from the first text, for the archive");
324 fresh()
325 }
326 None => fresh(),
327 }
328 }
329
330 fn save(&self) -> Result<(), String> {
331 self.save_at(&Reader::path())
332 }
333
334 fn save_at(&self, path: &Path) -> Result<(), String> {
335 write_whole(path, serde_json::to_string(self).map_err(|e| e.to_string())?.as_bytes())
336 }
337
338 fn measure(&mut self, answer: &Value, now: i64) {
340 let count = |name: &str| answer[name].as_array().map_or(0, Vec::len) as u64;
341 self.total_texts = count("sms") + count("mms");
342 self.total_media = answer["media"].as_u64().unwrap_or(0);
343 self.measured = now;
344 }
345
346 fn index(&mut self, log: &[Text]) {
350 let texts: Vec<&Text> = log.iter().filter(|t| t.deleted == 0).collect();
351 self.logged = Rows::of(texts.iter().filter_map(|t| t.row()));
352 self.newest_at = texts.iter().map(|t| t.at).max().unwrap_or(0);
353 self.done_texts = self.logged.len() as u64;
354 self.done_media = texts.iter().map(|t| t.media_bytes()).sum();
355 self.indexed = true;
356 }
357}
358
359fn listed(answer: &Value) -> Option<BTreeSet<rules::Row>> {
363 let mut out = BTreeSet::new();
364 for (mms, ids, dates) in [(false, "sms", "sms_at"), (true, "mms", "mms_at")] {
365 let (ids, dates) = (answer[ids].as_array()?, answer[dates].as_array()?);
366 if ids.len() != dates.len() {
367 return None;
368 }
369 for (id, date) in ids.iter().zip(dates) {
370 out.insert(rules::Row { mms, id: id.as_i64()?, date: date.as_i64()? });
371 }
372 }
373 Some(out)
374}
375
376#[derive(Debug, Clone, Default, Deserialize)]
378#[serde(default)]
379struct Raw {
380 kind: String,
381 fields: BTreeMap<String, Value>,
382 addrs: Vec<BTreeMap<String, Value>>,
383 parts: Vec<BTreeMap<String, Value>>,
384}
385
386fn int(fields: &BTreeMap<String, Value>, name: &str) -> i64 {
387 fields.get(name).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))).unwrap_or(0)
388}
389
390fn string(fields: &BTreeMap<String, Value>, name: &str) -> String {
391 fields.get(name).and_then(Value::as_str).unwrap_or_default().to_string()
392}
393
394const MMS_FROM: i64 = 137;
396const MMS_TO: i64 = 151;
397const MMS_NOTIFICATION: i64 = 130;
399const OWN_ADDRESS: &str = "insert-address-token";
401
402fn part_of(columns: &BTreeMap<String, Value>, waiting: bool, cap: u64) -> Part {
405 let ct = string(columns, "ct");
406 let words = ct.starts_with("text/plain") || ct == "application/smil";
407 let size = int(columns, "size");
408 let state = if words {
409 "text"
410 } else if waiting || size < 0 {
411 "not-downloaded"
412 } else if u64::try_from(size).unwrap_or(0) > cap {
413 "too-big"
414 } else {
415 "here"
416 };
417 let name = ["name", "cl", "fn"].iter().map(|n| string(columns, n)).find(|n| !n.is_empty()).unwrap_or_default();
418 let mut fields = columns.clone();
420 for held in ["seq", "ct", "text", "size"] {
421 fields.remove(held);
422 }
423 Part { seq: int(columns, "seq"), ct, name, size: u64::try_from(size).unwrap_or(0), text: if words { string(columns, "text") } else { String::new() }, hash: String::new(), state: state.into(), fields }
424}
425
426fn text_of(raw: &Raw, threads: &BTreeMap<String, Vec<String>>, region: Option<&phones::Region>, cap: u64) -> Text {
430 let key = |a: &str| phones::key(a, region);
431 let f = &raw.fields;
432 let thread = int(f, "thread_id").to_string();
433 let people: Vec<String> = threads.get(&thread).map(|list| list.iter().map(|a| key(a)).filter(|k| !k.is_empty()).collect()).unwrap_or_default();
434 let sub = i32::try_from(int(f, "sub_id")).ok().filter(|_| f.contains_key("sub_id")).unwrap_or(-1);
435 if raw.kind == "mms" {
436 let outgoing = matches!(int(f, "msg_box"), 2 | 4);
437 let addresses = |kind: i64| -> Vec<String> { raw.addrs.iter().filter(|a| int(a, "type") == kind).map(|a| string(a, "address")).filter(|a| !a.is_empty() && a != OWN_ADDRESS).map(|a| key(&a)).filter(|k| !k.is_empty()).collect() };
438 let from = addresses(MMS_FROM).into_iter().next().unwrap_or_default();
439 let with = if !people.is_empty() {
440 people
441 } else if outgoing {
442 addresses(MMS_TO)
443 } else {
444 [from.clone()].into_iter().filter(|k| !k.is_empty()).collect()
445 };
446 let waiting = int(f, "m_type") == MMS_NOTIFICATION;
447 let parts: Vec<Part> = raw.parts.iter().map(|p| part_of(p, waiting, cap)).collect();
448 let body = parts.iter().filter(|p| p.ct.starts_with("text/plain")).map(|p| p.text.as_str()).collect::<Vec<_>>().join("\n");
449 Text {
450 id: rules::Row { mms: true, id: int(f, "_id"), date: int(f, "date") }.name(),
451 at: int(f, "date") * 1000,
453 thread,
454 direction: if outgoing { "out".into() } else { "in".into() },
455 from: if outgoing || with.len() < 2 { String::new() } else { from },
456 with,
457 body,
458 sub,
459 picture: parts.iter().any(|p| p.ct.starts_with("image/")),
460 parts,
461 fields: f.clone(),
462 addrs: raw.addrs.clone(),
463 ..Text::default()
464 }
465 } else {
466 let address = key(&string(f, "address"));
467 let with = if !people.is_empty() { people } else { [address.clone()].into_iter().filter(|k| !k.is_empty()).collect() };
468 let outgoing = matches!(int(f, "type"), 2 | 4 | 5 | 6);
469 Text {
470 id: rules::Row { mms: false, id: int(f, "_id"), date: int(f, "date") }.name(),
471 at: int(f, "date"),
472 thread,
473 direction: if outgoing { "out".into() } else { "in".into() },
474 from: if outgoing || with.len() < 2 { String::new() } else { address },
475 with,
476 body: string(f, "body"),
477 sub,
478 fields: f.clone(),
479 ..Text::default()
480 }
481 }
482}
483
484fn raws(answer: &Value) -> Vec<Raw> {
485 answer["texts"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default()
486}
487
488fn bring_media(android: &dyn Android, incoming: &Path, text: &mut Text, vault: &Vault, budget: &mut u64) -> bool {
494 let (folder, key) = vault;
495 for part in text.parts.iter_mut().filter(|p| p.state == "here" && p.hash.is_empty()) {
496 if part.size > *budget && *budget < rules::MEDIA_BATCH {
497 return false;
498 }
499 let copy = incoming.join(format!("{}-{}", text.id, part.seq));
500 let answer = android.ask("texts-part", &json!({ "id": int(&part.fields, "_id"), "target": copy.display().to_string() }));
501 if answer["allowed"] != true {
502 return false;
503 }
504 if answer["copied"].as_i64().unwrap_or(-1) < 0 {
505 part.state = "not-downloaded".into();
507 continue;
508 }
509 let put = sioul_sync::blobs::hash_file(©).map_err(|e| e.to_string()).and_then(|(hash, size)| sioul_sync::blobs::put(folder, key, ©, &hash).map(|_| (hash, size)));
510 let _ = std::fs::remove_file(©);
511 match put {
512 Ok((hash, size)) => {
513 part.hash = hash;
514 part.size = size;
515 *budget = budget.saturating_sub(size);
516 }
517 Err(e) => {
518 eprintln!("sioul: texts: {e}");
519 return false;
520 }
521 }
522 }
523 true
524}
525
526fn threads_of(answer: &Value) -> BTreeMap<String, Vec<String>> {
528 answer["threads"]
529 .as_object()
530 .map(|o| o.iter().map(|(id, people)| (id.clone(), people.as_array().map(|a| a.iter().filter_map(|p| p.as_str().map(str::to_string)).collect()).unwrap_or_default())).collect())
531 .unwrap_or_default()
532}
533
534#[derive(Default)]
536struct Read {
537 texts: Vec<Text>,
538 media: u64,
539}
540
541fn read_rows(android: &dyn Android, files: &Files, wanted: &[rules::Row], vault: &Vault, region: Option<&phones::Region>, budget: &mut u64) -> Option<Read> {
548 if wanted.is_empty() {
549 return Some(Read::default());
550 }
551 let ids = |mms: bool| wanted.iter().filter(|r| r.mms == mms).map(|r| r.id).collect::<Vec<_>>();
552 let answer = android.ask("texts-read", &json!({ "sms_ids": ids(false), "mms_ids": ids(true), "limit": wanted.len() }));
553 if answer["allowed"] != true {
554 return None;
555 }
556 let threads = threads_of(&answer);
557 let cap = cap_mb() << 20;
558 let mut rows: BTreeMap<(bool, i64), Raw> = raws(&answer).into_iter().map(|raw| ((raw.kind == "mms", int(&raw.fields, "_id")), raw)).collect();
559 let mut read = Read::default();
560 let mut mms_on = true;
561 for row in wanted {
562 if row.mms && !mms_on {
563 continue;
564 }
565 let Some(raw) = rows.remove(&(row.mms, row.id)) else { continue };
566 if int(&raw.fields, "date") != row.date {
567 continue;
568 }
569 let mut text = text_of(&raw, &threads, region, cap);
570 if row.mms {
571 let before = *budget;
572 if !bring_media(android, &files.incoming, &mut text, vault, budget) {
573 mms_on = false;
574 continue;
575 }
576 read.media += before - *budget;
577 }
578 read.texts.push(text);
579 }
580 Some(read)
581}
582
583fn write_log(log: &Path, seal: &Seal, texts: &[Text]) -> Result<usize, String> {
585 let line = |t: &Text| t.line_id();
586 rules::append(log, texts, seal, Some(&line))
587}
588
589#[allow(clippy::too_many_arguments)]
604fn import_step(android: &dyn Android, files: &Files, seal: &Seal, vault: &Vault, region: Option<&phones::Region>, now: i64, all: bool) -> bool {
605 let mut reader = Reader::load_at(&files.reader);
606 let loaded = reader.clone();
607 let first = reader.started == 0;
608 if first {
609 reader.started = now;
610 }
611 let older = !reader.indexed;
613 if older {
614 reader.index(&rules::read_file::<Text>(&files.log, seal));
615 }
616 let answer = android.ask("texts-ids", &json!({ "measure": false }));
617 let Some(listed) = (answer["allowed"] == true).then(|| listed(&answer)).flatten() else {
618 if first || older {
619 let _ = reader.save_at(&files.reader);
620 }
621 return false;
622 };
623 let logged = reader.logged.set();
624 let (was_backlog, was_arrived) = (reader.backlog.set(), reader.arrived.set());
625 let unread: Vec<rules::Row> = listed.iter().filter(|r| !logged.contains(r)).copied().collect();
626 let fresh: Vec<rules::Row> = unread.iter().filter(|r| !was_backlog.contains(r) && !was_arrived.contains(r)).copied().collect();
627 let mut backlog: BTreeSet<rules::Row> = unread.iter().filter(|r| was_backlog.contains(r)).copied().collect();
628 let mut arrived: BTreeSet<rules::Row> = unread.iter().filter(|r| was_arrived.contains(r)).copied().collect();
629 if first || older || fresh.len() >= rules::BATCH {
630 if !fresh.is_empty() {
633 reader.since = now;
634 }
635 backlog.extend(fresh);
636 } else {
637 arrived.extend(fresh);
638 }
639 let floor = reader.since.max(reader.started) - DAY_MS;
645 let mut came: Vec<rules::Row> = arrived.iter().copied().collect();
646 came.sort_by_key(|r| (r.at(), r.id));
647 let mut history: Vec<rules::Row> = backlog.iter().copied().filter(|r| all || r.at() >= floor).collect();
648 history.sort_by(|a, b| (b.at(), b.id).cmp(&(a.at(), a.id)));
649 let mut wanted: Vec<rules::Row> = Vec::new();
650 let mut pictures = 0;
651 for row in came.into_iter().chain(history) {
652 if wanted.len() >= rules::BATCH {
653 break;
654 }
655 if row.mms {
656 if pictures >= MMS_SHARE {
657 continue;
658 }
659 pictures += 1;
660 }
661 wanted.push(row);
662 }
663 let mut budget = rules::MEDIA_BATCH;
664 let read = read_rows(android, files, &wanted, vault, region, &mut budget).unwrap_or_default();
665 let gone: BTreeSet<(bool, i64)> = logged.difference(&listed).map(|r| (r.mms, r.id)).collect();
667 let marks = rules::Marks::read_all(files.log.parent().and_then(Path::parent).unwrap_or(Path::new("")), seal);
669 let shown: Vec<Text> = read.texts.iter().filter(|t| !marks.hides(t)).cloned().collect();
670 let written = match write_log(&files.log, seal, &shown) {
671 Ok(written) => {
672 let rows: Vec<rules::Row> = read.texts.iter().filter_map(Text::row).collect();
674 if rows.iter().any(|r| gone.contains(&(r.mms, r.id))) {
676 let marks = rules::moved_marks(&rules::read_file::<Text>(&files.log, seal), &listed, now);
677 if let Err(e) = write_log(&files.log, seal, &marks) {
678 eprintln!("sioul: texts: {e}");
679 }
680 }
681 for row in &rows {
682 backlog.remove(row);
683 arrived.remove(row);
684 }
685 reader.logged = Rows::of(logged.into_iter().chain(rows));
686 reader.newest_at = read.texts.iter().map(|t| t.at).fold(reader.newest_at, i64::max);
687 reader.done_media += read.media;
688 written
689 }
690 Err(e) => {
691 eprintln!("sioul: texts: {e}");
693 0
694 }
695 };
696 reader.backlog = Rows::of(backlog);
697 reader.arrived = Rows::of(arrived);
698 reader.behind = !reader.backlog.is_empty();
699 reader.left = !reader.arrived.is_empty();
700 reader.done_texts = reader.logged.len() as u64;
701 if reader != loaded
703 && let Err(e) = reader.save_at(&files.reader)
704 {
705 eprintln!("sioul: texts: {e}");
706 }
707 if written > 0 {
708 eprintln!("sioul: texts: {written} read for your computers ({} so far, {} bytes of media)", reader.done_texts, reader.done_media);
710 }
711 written > 0
712}
713
714fn look_again(android: &dyn Android, files: &Files, seal: &Seal, now: i64) -> bool {
721 let mut reader = Reader::load_at(&files.reader);
722 if now - reader.looked < DAY_MS {
723 return false;
724 }
725 let answer = android.ask("texts-ids", &json!({ "measure": true }));
726 if answer["allowed"] != true {
727 return false;
728 }
729 let Some(listed) = listed(&answer) else {
730 reader.looked = now - DAY_MS + LOOK_AGAIN_MS;
734 if let Err(e) = reader.save_at(&files.reader) {
735 eprintln!("sioul: texts: {e}");
736 }
737 return false;
738 };
739 reader.looked = now;
740 reader.measure(&answer, now);
741 let log: Vec<Text> = rules::read_file(&files.log, seal);
742 reader.index(&log);
743 if let Err(e) = reader.save_at(&files.reader) {
744 eprintln!("sioul: texts: {e}");
745 }
746 let mut marks = rules::moved_marks(&log, &listed, now);
749 let moved: BTreeSet<String> = marks.iter().map(|t| t.id.clone()).collect();
750 let marked: BTreeSet<&str> = log.iter().filter(|t| t.deleted > 0).map(|t| t.id.as_str()).collect();
751 marks.extend(
752 log.iter()
753 .filter(|t| t.deleted == 0 && !marked.contains(t.id.as_str()) && !moved.contains(&t.id) && t.row().is_some_and(|r| !listed.contains(&r)))
754 .map(|t| Text { id: t.id.clone(), at: t.at, direction: t.direction.clone(), with: t.with.clone(), deleted: now, ..Text::default() }),
755 );
756 let written = write_log(&files.log, seal, &marks).unwrap_or_else(|e| {
757 eprintln!("sioul: texts: {e}");
758 0
759 });
760 if written > 0 {
761 eprintln!("sioul: texts: {written} deleted on the phone, marked so");
762 }
763 written > 0
764}
765
766const LOOK_AGAIN_MS: i64 = 3_600_000;
770
771#[derive(Debug, Clone, Default, Deserialize)]
775#[serde(default)]
776struct PartResult {
777 key: String,
778 kind: String,
779 part: i64,
780 parts: i64,
781 code: i64,
782 uri: String,
783 status: i64,
784 at: i64,
785 http: i64,
786 conf: String,
787}
788
789fn reason_of(code: i64) -> &'static str {
791 match code {
792 2 => "radio-off",
793 4 => "no-service",
794 5 => "limit",
795 18 | 30 | 31 => "no-sim",
796 _ => "other",
797 }
798}
799
800fn row_of(uri: &str) -> String {
803 let uri = uri.trim();
804 let table = if uri.starts_with("content://mms/") { "mms" } else { "sms" };
805 uri.rsplit('/').next().filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())).map(|id| format!("{table}-{id}")).unwrap_or_default()
806}
807
808fn outcomes_from(results: &[PartResult]) -> Vec<Outcome> {
812 let mut by: BTreeMap<&str, Vec<&PartResult>> = BTreeMap::new();
813 for r in results.iter().filter(|r| !r.key.is_empty() && r.kind != "mms") {
814 by.entry(r.key.as_str()).or_default().push(r);
815 }
816 let mut out = Vec::new();
817 for (key, parts) in by {
818 let count = parts.iter().map(|p| p.parts.max(1)).max().unwrap_or(1);
819 let sent: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "sent").collect();
820 let at = |list: &[&&PartResult]| list.iter().map(|p| p.at).max().unwrap_or(0);
821 if let Some(failed) = sent.iter().find(|p| p.code != -1) {
822 out.push(Outcome { key: key.into(), state: "failed".into(), at: failed.at, reason: reason_of(failed.code).into(), ..Outcome::default() });
823 continue;
824 }
825 let done: BTreeSet<i64> = sent.iter().map(|p| p.part).collect();
826 if done.len() as i64 >= count {
827 let row = sent.iter().map(|p| row_of(&p.uri)).find(|r| !r.is_empty()).unwrap_or_default();
828 out.push(Outcome { key: key.into(), state: "sent".into(), at: at(&sent), row, ..Outcome::default() });
829 let delivered: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "delivered" && (0..0x20).contains(&p.status)).collect();
830 if delivered.iter().map(|p| p.part).collect::<BTreeSet<_>>().len() as i64 >= count {
831 out.push(Outcome { key: key.into(), state: "delivered".into(), at: at(&delivered), ..Outcome::default() });
832 }
833 }
834 }
835 out
836}
837
838fn unhex(hex: &str) -> Option<Vec<u8>> {
840 let hex = hex.trim();
841 if hex.len() % 2 != 0 {
842 return None;
843 }
844 (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect()
845}
846
847fn mms_outcomes(results: &[PartResult]) -> (Vec<Outcome>, Vec<(String, i64)>) {
854 let (mut out, mut look) = (Vec::new(), Vec::new());
855 for r in results.iter().filter(|r| r.kind == "mms" && !r.key.is_empty()) {
856 let conf = unhex(&r.conf).and_then(|bytes| sioul_core::mms::conf(&bytes));
857 match rules::took(r.code, conf.as_ref(), r.http) {
858 rules::Took::Sent => out.push(Outcome { key: r.key.clone(), state: "sent".into(), at: r.at, row: row_of(&r.uri), ..Outcome::default() }),
859 rules::Took::Failed(why) => out.push(Outcome { key: r.key.clone(), state: "failed".into(), at: r.at, reason: why.into(), ..Outcome::default() }),
860 rules::Took::Doubt(why) => out.push(Outcome { key: r.key.clone(), state: "doubt".into(), at: r.at, reason: why.into(), ..Outcome::default() }),
861 rules::Took::Look => look.push((r.key.clone(), r.at)),
862 }
863 }
864 (out, look)
865}
866
867fn looked(answer: &Value) -> bool {
871 answer["allowed"] == true && answer["error"] != true
872}
873
874fn sent_row(answer: &Value) -> Option<String> {
878 answer["found"].as_array()?.iter().find(|row| row["box"] == 2).map(|row| rules::Row { mms: true, id: row["id"].as_i64().unwrap_or(0), date: row["date"].as_i64().unwrap_or(0) }.name())
879}
880
881fn failed_status(answer: &Value) -> Option<u8> {
886 answer["found"].as_array()?.iter().filter(|row| row["box"] == 5).filter_map(|row| u8::try_from(row["status"].as_i64()?).ok()).find(|status| *status > sioul_core::mms::OK)
887}
888
889const RESULTS_ASIDE: &str = "results-aside.jsonl";
892
893fn results_of(private: &Path, now: i64) -> Vec<PartResult> {
898 [rules::RESULTS, RESULTS_ASIDE]
899 .iter()
900 .flat_map(|name| std::fs::read_to_string(private.join(name)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<PartResult>(l).ok()).collect::<Vec<_>>())
901 .filter(|r| now - r.at <= rules::KEPT_DAYS * DAY_MS)
902 .collect()
903}
904
905fn trim_results(private: &Path, now: i64) -> std::io::Result<()> {
913 let current = private.join(rules::RESULTS);
914 let aside = private.join(RESULTS_ASIDE);
915 let kept = |text: &str| -> Vec<String> { text.lines().filter(|l| serde_json::from_str::<PartResult>(l).is_ok_and(|r| now - r.at <= rules::KEPT_DAYS * DAY_MS)).map(str::to_string).collect() };
916 let append = |lines: &[String]| -> std::io::Result<()> {
917 if lines.is_empty() {
918 return Ok(());
919 }
920 use std::io::Write;
921 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(¤t)?;
922 file.write_all(lines.iter().map(|l| format!("{l}\n")).collect::<String>().as_bytes())?;
923 file.sync_all()
924 };
925 if let Ok(text) = std::fs::read_to_string(&aside) {
926 let there: BTreeSet<String> = std::fs::read_to_string(¤t).unwrap_or_default().lines().map(str::to_string).collect();
927 append(&kept(&text).into_iter().filter(|l| !there.contains(l)).collect::<Vec<_>>())?;
928 std::fs::remove_file(&aside)?;
929 }
930 match std::fs::rename(¤t, &aside) {
931 Ok(()) => append(&kept(&std::fs::read_to_string(&aside)?)),
932 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
933 Err(e) => Err(e),
934 }
935}
936
937fn limits_from(answer: &Value) -> Vec<rules::Limits> {
939 answer["limits"].as_array().map(|all| all.iter().filter_map(|l| serde_json::from_value::<rules::Limits>(l.clone()).ok()).filter(|l| l.max > 0).collect()).unwrap_or_default()
940}
941
942const ANDROID_MAX: u64 = 300 * 1024;
944
945fn sealed_here(vault: &Vault, hash: &str) -> bool {
948 let (folder, key) = vault;
949 let there = |dir: &Path| std::fs::metadata(dir.join("blobs").join(sioul_sync::blobs::name(key, hash))).is_ok_and(|m| m.len() > 0);
950 there(folder) || sioul_sync::remote::overlay(folder).is_some_and(|copy| there(©))
951}
952
953#[derive(Debug, Clone, PartialEq, Eq)]
955enum Prepared {
956 Ready { sub: i32 },
958 Waiting,
960 Refused(&'static str),
962}
963
964struct Step<'a> {
969 here: &'a str,
970 seal: &'a Seal,
971 region: Option<&'static phones::Region>,
972 root: PathBuf,
973 private: PathBuf,
974 copies: Vec<PathBuf>,
975 memory: PathBuf,
976 vault: Option<&'a Vault>,
977 android: &'a dyn Android,
978 standing: &'a dyn Fn(&str) -> rules::Standing,
982}
983
984impl Step<'_> {
985 fn here<'a>(here: &'a str, seal: &'a Seal, region: Option<&'static phones::Region>, vault: Option<&'a Vault>, standing: &'a dyn Fn(&str) -> rules::Standing) -> Step<'a> {
987 Step { here, seal, region, root: root(), private: private(), copies: crate::share::texts_copies(), memory: crate::share::memory_path(), vault, android: &Phone, standing }
988 }
989
990 fn say(&self, outcomes: &[Outcome]) -> bool {
992 let id = |o: &Outcome| format!("{}/{}", o.key, o.state);
993 match rules::append(&rules::own_file(&self.root, rules::OUTCOME, self.here), outcomes, self.seal, Some(&id)) {
994 Ok(n) => n > 0,
995 Err(e) => {
996 eprintln!("sioul: texts: {e}");
997 false
998 }
999 }
1000 }
1001
1002 fn record(&self, claim: &Claim) {
1003 if let Err(e) = rules::record(&self.private, claim) {
1004 eprintln!("sioul: texts: {e}");
1005 }
1006 }
1007
1008 fn pdu_path(&self, key: &str) -> PathBuf {
1012 self.private.join(OUTGOING).join(format!("{}.pdu", key.chars().filter(char::is_ascii_hexdigit).collect::<String>()))
1013 }
1014
1015 fn copy_results(&self, now: i64) -> bool {
1022 let results = results_of(&self.private, now);
1023 if results.is_empty() {
1024 return false;
1025 }
1026 let said: BTreeSet<String> = rules::read_file::<Outcome>(&rules::own_file(&self.root, rules::OUTCOME, self.here), self.seal).iter().map(|o| format!("{}/{}", o.key, o.state)).collect();
1027 let mut outcomes = outcomes_from(&results);
1028 let (mms, look) = mms_outcomes(&results);
1029 outcomes.extend(mms);
1030 for (key, at) in look {
1031 if ["sent", "failed", "doubt"].iter().any(|state| said.contains(&format!("{key}/{state}"))) {
1032 continue;
1033 }
1034 let answer = self.android.ask("texts-find-mms", &json!({ "key": key }));
1035 if !looked(&answer) {
1038 continue;
1039 }
1040 outcomes.push(match (sent_row(&answer), failed_status(&answer)) {
1044 (Some(row), _) => Outcome { key, state: "sent".into(), at, row, ..Outcome::default() },
1045 (None, Some(status)) => Outcome { key, state: "failed".into(), at, reason: rules::mms_failure(-1, Some(status)).into(), ..Outcome::default() },
1046 (None, None) => Outcome { key, state: "doubt".into(), at, reason: "answer".into(), ..Outcome::default() },
1047 });
1048 }
1049 let mut new: Vec<Outcome> = outcomes.iter().filter(|o| !said.contains(&format!("{}/{}", o.key, o.state))).cloned().collect();
1050 name_rows(self.android, &mut new);
1051 let changed = self.say(&new);
1052 let ledger = rules::Ledger::open(&self.private, now).ok();
1053 for o in &outcomes {
1054 if ledger.as_ref().is_some_and(|l| l.state(&o.key) != Some("done")) {
1055 self.record(&Claim { key: o.key.clone(), at: now, state: "done".into(), ..Claim::default() });
1056 }
1057 let _ = std::fs::remove_file(self.pdu_path(&o.key));
1058 }
1059 changed
1060 }
1061
1062 fn prepare(&self, r: &Request, log: &std::cell::OnceCell<Vec<Text>>, now: i64) -> Prepared {
1072 let Some(vault) = self.vault else { return Prepared::Waiting };
1073 if r.key.len() != 32 || !r.key.bytes().all(|b| b.is_ascii_hexdigit()) {
1074 return Prepared::Refused("other");
1075 }
1076 let absent: Vec<rules::Medium> = r.media.iter().filter(|m| !sealed_here(vault, &m.hash)).cloned().collect();
1081 if !absent.is_empty() {
1082 self.want(&r.media, &absent);
1083 return Prepared::Waiting;
1084 }
1085 let to = phones::key(&r.to, self.region);
1086 let sub = self.sim_for(r, log);
1087 let answer = self.android.ask("texts-limits", &json!({ "sub": sub }));
1088 let limits = limits_from(&answer);
1089 let carrier = limits.iter().find(|l| l.sub == sub).or_else(|| limits.first());
1090 if carrier.is_some_and(|l| !l.mms) {
1091 return Prepared::Refused("no-mms");
1092 }
1093 let max = carrier.map_or(ANDROID_MAX, |l| l.max);
1094 if r.media.iter().map(|m| m.size).sum::<u64>() > max {
1096 return Prepared::Refused("too-large");
1097 }
1098 let (folder, key) = vault;
1099 let _ = std::fs::create_dir_all(self.private.join(OUTGOING));
1100 let opened = self.private.join(OUTGOING).join(format!("{}.media", self.pdu_path(&r.key).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()));
1101 let mut bytes = Vec::new();
1102 let mut missing = Vec::new();
1103 for medium in &r.media {
1104 let ok = sioul_sync::blobs::get(folder, key, &medium.hash, &opened).is_ok();
1105 let read = if ok { std::fs::read(&opened).ok() } else { None };
1106 let _ = std::fs::remove_file(&opened);
1107 match read {
1108 Some(read) if read.len() as u64 == medium.size => bytes.push(read),
1109 Some(_) => return Prepared::Refused("media-kind"),
1110 None => missing.push(medium.clone()),
1111 }
1112 }
1113 self.want(&r.media, &missing);
1114 if !missing.is_empty() {
1115 return Prepared::Waiting;
1116 }
1117 let media: Vec<sioul_core::mms::Medium> = r.media.iter().zip(&bytes).map(|(m, b)| sioul_core::mms::Medium { ct: &m.ct, bytes: b }).collect();
1118 let message = sioul_core::mms::Message { key: &r.key, to: &[to.as_str()], date: now / 1000, words: &r.words, media: &media };
1119 let Ok(pdu) = sioul_core::mms::encode(&message) else { return Prepared::Refused("other") };
1120 if pdu.len() as u64 > max {
1121 return Prepared::Refused("too-large");
1122 }
1123 match write_flushed(&self.pdu_path(&r.key), &pdu) {
1124 Ok(()) => Prepared::Ready { sub },
1125 Err(e) => {
1127 eprintln!("sioul: texts: {e}");
1128 Prepared::Waiting
1129 }
1130 }
1131 }
1132
1133 fn sim_for(&self, r: &Request, log: &std::cell::OnceCell<Vec<Text>>) -> i32 {
1139 if r.sub >= 0 {
1140 return r.sub;
1141 }
1142 let texts = log.get_or_init(|| {
1143 #[cfg(test)]
1144 LOG_READS.with(|reads| reads.set(reads.get() + 1));
1145 rules::read_logs(&self.root, self.seal)
1146 });
1147 rules::last_sim(&rules::conversation_id(&[phones::key(&r.to, self.region)]), self.here, texts)
1148 }
1149
1150 fn want(&self, media: &[rules::Medium], missing: &[rules::Medium]) {
1154 let Some((_, key)) = self.vault else { return };
1155 let name = |m: &rules::Medium| sioul_sync::blobs::name(key, &m.hash);
1156 let wanted = sioul_sync::blobs::Wanted::load(&self.memory);
1157 let asked = missing.iter().any(|m| !wanted.media.contains_key(&name(m)));
1158 let done = media.iter().filter(|m| !missing.contains(m)).any(|m| wanted.media.contains_key(&name(m)));
1159 if !asked && !done {
1160 return;
1161 }
1162 let now = jiff::Timestamp::now().as_second();
1163 let _ = sioul_sync::blobs::Wanted::change(&self.memory, |wanted| {
1164 for m in media.iter().filter(|m| !missing.contains(m)) {
1165 wanted.media.remove(&name(m));
1166 }
1167 for m in missing {
1168 wanted.media.entry(name(m)).or_insert_with(|| sioul_sync::blobs::Want { hash: m.hash.clone(), size: m.size, since: now, ..Default::default() });
1169 }
1170 });
1171 }
1172
1173 fn decide(&self, now: i64) -> bool {
1181 let roots: Vec<PathBuf> = std::iter::once(self.root.clone()).chain(self.copies.iter().cloned()).collect();
1186 let requests = requests_from(&roots, self.seal);
1187 if requests.iter().all(|r| r.phone != self.here) {
1188 return false;
1189 }
1190 let mine = |r: &&Request| r.phone == self.here;
1191 let log = std::cell::OnceCell::new();
1193 let mut ready: BTreeMap<String, Prepared> = BTreeMap::new();
1196 if let Ok(ledger) = rules::Ledger::open(&self.private, now) {
1197 for r in requests.iter().filter(mine).filter(|r| r.multimedia()) {
1198 if rules::verdict(r, self.here, now, &ledger, self.region, self.standing) == Verdict::Send {
1199 ready.insert(r.key.clone(), self.prepare(r, &log, now));
1200 }
1201 }
1202 }
1203 let ledger_path = self.private.join(rules::LEDGER);
1204 let mut said = Vec::new();
1205 let mut to_send = Vec::new();
1206 let mut to_send_mms: Vec<(Request, i32)> = Vec::new();
1207 let claimed = sioul_core::filelock::with_lock(&ledger_path, || -> Result<(), String> {
1208 let ledger = rules::Ledger::open(&self.private, now)?;
1209 for r in &requests {
1210 let verdict = rules::verdict(r, self.here, now, &ledger, self.region, self.standing);
1211 let (state, reason) = match verdict {
1212 Verdict::NotMine | Verdict::Decided | Verdict::Wait => continue,
1214 Verdict::Send if r.multimedia() => match ready.get(&r.key) {
1215 Some(Prepared::Ready { sub }) => {
1216 rules::record(&self.private, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, self.region), hash: rules::words_hash(&r.words), mms: true, ..Claim::default() })?;
1217 to_send_mms.push((r.clone(), *sub));
1218 continue;
1219 }
1220 Some(Prepared::Refused(why)) => ("refused", *why),
1221 _ => continue,
1223 },
1224 Verdict::Send => {
1225 rules::record(&self.private, &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, self.region), hash: rules::words_hash(&r.body), ..Claim::default() })?;
1226 to_send.push(r.clone());
1227 continue;
1228 }
1229 Verdict::Expired if r.multimedia() && self.vault.is_some_and(|vault| r.media.iter().any(|m| !sealed_here(vault, &m.hash))) => ("expired", "media"),
1230 Verdict::Expired => ("expired", ""),
1231 Verdict::Clocks => ("clocks", ""),
1232 Verdict::BeforeMark => ("refused", "before-mark"),
1233 Verdict::Refused(why) => ("refused", why),
1234 };
1235 rules::record(&self.private, &Claim { key: r.key.clone(), at: now, state: state.into(), ..Claim::default() })?;
1236 said.push(Outcome { key: r.key.clone(), state: state.into(), at: now, reason: reason.into(), ..Outcome::default() });
1237 if r.multimedia() {
1238 self.want(&r.media, &[]);
1240 }
1241 }
1242 Ok(())
1243 });
1244 for (key, prepared) in &ready {
1246 if !to_send_mms.iter().any(|(r, _)| &r.key == key) && *prepared != Prepared::Waiting {
1247 let _ = std::fs::remove_file(self.pdu_path(key));
1248 }
1249 }
1250 if let Err(e) = claimed {
1251 eprintln!("sioul: texts: {e}");
1253 for (r, _) in &to_send_mms {
1254 let _ = std::fs::remove_file(self.pdu_path(&r.key));
1255 }
1256 return self.say(&said);
1257 }
1258 for r in to_send {
1259 let sub = self.sim_for(&r, &log);
1261 let answer = self.android.ask("texts-send", &json!({ "key": r.key, "to": phones::key(&r.to, self.region), "body": r.body, "sub": sub }));
1262 if answer["handed"] == true {
1263 self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "handed".into(), ..Claim::default() });
1264 crate::replies::texted(&phones::key(&r.to, self.region));
1266 said.push(Outcome { key: r.key.clone(), state: "handed".into(), at: now_ms(), ..Outcome::default() });
1267 eprintln!("sioul: texts: one handed to Android ({} part(s))", answer["parts"].as_i64().unwrap_or(1));
1268 } else if answer.is_null() {
1269 eprintln!("sioul: texts: Android's answer did not come; the text is looked for later");
1271 } else {
1272 let reason = answer["reason"].as_str().unwrap_or("other").to_string();
1273 self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "done".into(), ..Claim::default() });
1274 said.push(Outcome { key: r.key.clone(), state: "failed".into(), at: now_ms(), reason, ..Outcome::default() });
1275 }
1276 }
1277 for (r, sub) in to_send_mms {
1278 let path = self.pdu_path(&r.key);
1279 let answer = self.android.ask("texts-send-mms", &json!({ "key": r.key, "sub": sub, "path": path }));
1280 if answer["handed"] == true {
1281 self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "handed".into(), ..Claim::default() });
1282 crate::replies::texted(&phones::key(&r.to, self.region));
1283 said.push(Outcome { key: r.key.clone(), state: "handed".into(), at: now_ms(), ..Outcome::default() });
1284 eprintln!("sioul: texts: one multimedia message handed to Android");
1285 } else if answer.is_null() {
1286 eprintln!("sioul: texts: Android's answer did not come; the multimedia message is looked for later");
1288 } else {
1289 let reason = answer["reason"].as_str().unwrap_or("other").to_string();
1290 self.record(&Claim { key: r.key.clone(), at: now_ms(), state: "done".into(), ..Claim::default() });
1291 said.push(Outcome { key: r.key.clone(), state: "failed".into(), at: now_ms(), reason, ..Outcome::default() });
1292 let _ = std::fs::remove_file(&path);
1293 }
1294 }
1295 self.say(&said)
1296 }
1297
1298 fn look_for_lost(&self, now: i64) -> bool {
1307 let Ok(ledger) = rules::Ledger::open(&self.private, now) else { return false };
1308 let lost: Vec<&Claim> = ledger.claims.iter().filter(|c| c.state == "claimed" && !c.mms && ledger.state(&c.key) == Some("claimed") && now - c.at >= rules::LOOK_AFTER_MS).collect();
1309 let lost_mms: Vec<&Claim> = ledger.claims.iter().filter(|c| c.state == "claimed" && c.mms && matches!(ledger.state(&c.key), Some("claimed" | "handed")) && now - c.at >= rules::LOOK_AFTER_MMS_MS).collect();
1310 let mut said = Vec::new();
1311 if !lost.is_empty() {
1312 let since = lost.iter().map(|c| c.at).min().unwrap_or(now) - 60_000;
1313 let answer = self.android.ask("texts-find", &json!({ "since": since }));
1314 if answer["allowed"] == true {
1315 let sent: Vec<Raw> = raws(&answer);
1316 for claim in lost {
1317 let found = sent
1318 .iter()
1319 .find(|r| int(&r.fields, "date") >= claim.at - 60_000 && rules::words_hash(&string(&r.fields, "body")) == claim.hash && phones::key(&string(&r.fields, "address"), self.region) == claim.to)
1320 .map(|r| rules::Row { mms: false, id: int(&r.fields, "_id"), date: int(&r.fields, "date") }.name());
1321 if let Some(outcome) = rules::doubt_after(claim, found, now) {
1322 self.record(&Claim { key: claim.key.clone(), at: now, state: "done".into(), ..Claim::default() });
1323 said.push(outcome);
1324 }
1325 }
1326 }
1327 }
1328 for claim in lost_mms {
1329 let answer = self.android.ask("texts-find-mms", &json!({ "key": claim.key }));
1330 if !looked(&answer) && now - claim.at < rules::MMS_LAST_WORD_MS {
1332 continue;
1333 }
1334 let found = if looked(&answer) { sent_row(&answer) } else { None };
1335 if let Some(outcome) = rules::doubt_after(claim, found, now) {
1336 self.record(&Claim { key: claim.key.clone(), at: now, state: "done".into(), ..Claim::default() });
1337 let _ = std::fs::remove_file(self.pdu_path(&claim.key));
1338 said.push(outcome);
1339 }
1340 }
1341 self.say(&said)
1342 }
1343
1344 fn publish_limits(&self, now: i64) -> bool {
1349 let limits = limits_from(&self.android.ask("texts-limits", &json!({})));
1350 if limits.is_empty() {
1351 return false;
1352 }
1353 let own = rules::read_file::<Outcome>(&rules::own_file(&self.root, rules::OUTCOME, self.here), self.seal);
1354 let last = own.iter().filter(|o| o.key.is_empty() && o.state == "limits").max_by_key(|o| o.at);
1355 if last.is_some_and(|o| o.limits == limits && (0..DAY_MS).contains(&(now - o.at))) {
1356 return false;
1357 }
1358 let line = Outcome { state: "limits".into(), at: now, limits, ..Outcome::default() };
1359 let id = |o: &Outcome| format!("limits/{}", o.at);
1360 match rules::append(&rules::own_file(&self.root, rules::OUTCOME, self.here), std::slice::from_ref(&line), self.seal, Some(&id)) {
1361 Ok(n) => n > 0,
1362 Err(e) => {
1363 eprintln!("sioul: texts: {e}");
1364 false
1365 }
1366 }
1367 }
1368
1369 fn sweep_outgoing(&self, now: i64) {
1373 let Ok(entries) = std::fs::read_dir(self.private.join(OUTGOING)) else { return };
1374 let ledger = rules::Ledger::open(&self.private, now).ok();
1375 for entry in entries.filter_map(Result::ok) {
1376 let name = entry.file_name().to_string_lossy().to_string();
1377 let key = name.trim_end_matches(".pdu").trim_end_matches(".media");
1378 let state = ledger.as_ref().and_then(|l| l.state(key).map(str::to_string));
1379 let old = entry.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
1380 if state.as_deref() == Some("done") || (state.is_none() && old) || (state.as_deref().is_some_and(|s| !matches!(s, "claimed" | "handed")) && old) {
1381 let _ = std::fs::remove_file(entry.path());
1382 }
1383 }
1384 }
1385}
1386
1387const OUTGOING: &str = "outgoing";
1389
1390#[cfg(test)]
1391thread_local! {
1392 static LOG_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1394}
1395
1396fn write_flushed(path: &Path, bytes: &[u8]) -> Result<(), String> {
1398 use std::io::Write;
1399 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1400 if let Some(dir) = path.parent() {
1401 std::fs::create_dir_all(dir).map_err(fail)?;
1402 }
1403 let beside = path.with_extension("part");
1404 let mut options = std::fs::OpenOptions::new();
1405 options.create(true).write(true).truncate(true);
1406 #[cfg(unix)]
1407 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1408 let mut file = options.open(&beside).map_err(fail)?;
1409 file.write_all(bytes).map_err(fail)?;
1410 file.sync_all().map_err(fail)?;
1411 std::fs::rename(&beside, path).map_err(fail)
1412}
1413
1414static LIMITS_ASKED: Mutex<i64> = Mutex::new(0);
1416static RESULTS_TRIMMED: Mutex<i64> = Mutex::new(0);
1418
1419fn standing_in(counted: &BTreeSet<String>, gone: &BTreeSet<String>, device: &str) -> rules::Standing {
1421 if counted.contains(device) {
1422 rules::Standing::Counted
1423 } else if gone.contains(device) {
1424 rules::Standing::Gone
1425 } else {
1426 rules::Standing::Unknown
1427 }
1428}
1429
1430pub(crate) fn standing_of(folder: &Path, key: &[u8; 32], here: &str, now: i64) -> impl Fn(&str) -> rules::Standing {
1433 let (counted, gone) = sioul_sync::share::asking(folder, key, &crate::share::memory_path(), here, now);
1434 move |device: &str| standing_in(&counted, &gone, device)
1435}
1436
1437fn light_step(here: &str, seal: &Seal, region: Option<&'static phones::Region>, vault: Option<&Vault>) -> bool {
1443 let standings: std::cell::OnceCell<(BTreeSet<String>, BTreeSet<String>)> = std::cell::OnceCell::new();
1445 let standing = |device: &str| {
1446 let (counted, gone) = standings.get_or_init(|| vault.map(|(folder, key)| sioul_sync::share::asking(folder, key, &crate::share::memory_path(), here, now_ms())).unwrap_or_default());
1447 standing_in(counted, gone, device)
1448 };
1449 let step = Step::here(here, seal, region, vault, &standing);
1450 sioul_core::filelock::with_lock(&step.private.join(".step"), || {
1451 let now = now_ms();
1452 let mut changed = step.copy_results(now);
1453 let trim_due = RESULTS_TRIMMED.lock().is_ok_and(|mut last| {
1454 let due = now - *last >= DAY_MS;
1455 if due {
1456 *last = now;
1457 }
1458 due
1459 });
1460 if trim_due && let Err(e) = trim_results(&step.private, now) {
1461 eprintln!("sioul: texts: Java's results not trimmed: {e}");
1462 }
1463 changed |= step.decide(now);
1464 changed |= step.look_for_lost(now);
1465 let due = LIMITS_ASKED.lock().is_ok_and(|mut asked| {
1466 let due = now - *asked >= 3_600_000;
1467 if due {
1468 *asked = now;
1469 }
1470 due
1471 });
1472 if due {
1473 changed |= step.publish_limits(now);
1474 }
1475 step.sweep_outgoing(now);
1476 changed
1477 })
1478}
1479
1480fn name_rows(android: &dyn Android, outcomes: &mut [Outcome]) {
1487 let bare = |row: &str, table: &str| row.strip_prefix(table).and_then(|rest| rest.strip_prefix('-')).filter(|id| !id.contains('@')).and_then(|id| id.parse::<i64>().ok());
1488 let sms_ids: Vec<i64> = outcomes.iter().filter_map(|o| bare(&o.row, "sms")).collect();
1489 let mms_ids: Vec<i64> = outcomes.iter().filter_map(|o| bare(&o.row, "mms")).collect();
1490 if sms_ids.is_empty() && mms_ids.is_empty() {
1491 return;
1492 }
1493 let answer = android.ask("texts-read", &json!({ "sms_ids": sms_ids, "mms_ids": mms_ids, "limit": sms_ids.len() + mms_ids.len() }));
1494 let dates: BTreeMap<(bool, i64), i64> = if answer["allowed"] == true { raws(&answer).iter().map(|r| ((r.kind == "mms", int(&r.fields, "_id")), int(&r.fields, "date"))).collect() } else { BTreeMap::new() };
1495 for o in outcomes.iter_mut() {
1496 for (mms, table) in [(false, "sms"), (true, "mms")] {
1497 if let Some(id) = bare(&o.row, table) {
1498 o.row = dates.get(&(mms, id)).map(|date| rules::Row { mms, id, date: *date }.name()).unwrap_or_default();
1499 }
1500 }
1501 }
1502}
1503
1504fn requests_from(roots: &[PathBuf], seal: &Seal) -> Vec<Request> {
1507 let mut requests: Vec<Request> = Vec::new();
1508 for root in roots {
1509 for request in rules::read_requests(root, seal) {
1510 if !requests.iter().any(|known| known.key == request.key) {
1511 requests.push(request);
1512 }
1513 }
1514 }
1515 requests
1516}
1517
1518fn heavy_step(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>) -> bool {
1524 sioul_core::filelock::with_lock(&private().join(".import"), || {
1525 let now = now_ms();
1526 let (texts_changed, plugged) = changes(true);
1527 let reader = Reader::load();
1528 let files = Files::of(here);
1529 let mut changed = false;
1530 if import_now(&reader, texts_changed, plugged, now) {
1531 changed = import_step(&Phone, &files, seal, vault, region, now, plugged);
1532 } else if recent_now(&reader, texts_changed) {
1533 changed = import_step(&Phone, &files, seal, vault, region, now, false);
1534 }
1535 changed |= look_again(&Phone, &files, seal, now);
1536 match rules::drop_hidden(&files.log, &rules::Marks::read_all(&root(), seal), seal) {
1538 Ok(gone) => changed |= gone > 0,
1539 Err(e) => eprintln!("sioul: texts: {e}"),
1540 }
1541 changed
1542 })
1543}
1544
1545fn changes(clear: bool) -> (bool, bool) {
1551 let answer = crate::steps::java("texts-changes", &json!({ "clear": clear }).to_string());
1552 (answer["changed"] != false, answer["plugged"] == true)
1553}
1554
1555fn import_now(reader: &Reader, changed: bool, plugged: bool, now: i64) -> bool {
1564 if reader.started == 0 || reader.behind { plugged } else { changed || reader.left || !reader.indexed || now - reader.looked >= DAY_MS }
1565}
1566
1567fn recent_now(reader: &Reader, changed: bool) -> bool {
1574 reader.started == 0 || !reader.indexed || (reader.behind && changed)
1575}
1576
1577pub(crate) fn importing_in_bulk() -> bool {
1582 phone() && sealed().is_some() && Reader::load().behind
1583}
1584
1585pub(crate) fn phone_step() -> bool {
1588 if !phone() {
1589 return false;
1590 }
1591 let Some((here, seal, Some(vault))) = sealed() else { return false };
1592 let region = sioul_core::reach::region(&load_config());
1593 let quick = light_step(&here, &seal, region, Some(&vault));
1594 heavy_step(&here, &seal, &vault, region) || quick
1595}
1596
1597static TRIMMED: Mutex<i64> = Mutex::new(0);
1601static WORKING: AtomicBool = AtomicBool::new(false);
1603
1604pub(crate) fn before_exchange(qt: Option<&QtThread>) {
1613 let Some((here, seal, vault)) = sealed() else { return };
1614 if phone() {
1615 light_step(&here, &seal, sioul_core::reach::region(&load_config()), vault.as_ref());
1616 } else {
1617 measure_clock();
1618 }
1619 if vault.is_some() && !crate::steps::in_service() && !WORKING.swap(true, Ordering::AcqRel) {
1620 let qt = qt.cloned();
1621 std::thread::spawn(move || {
1622 let mut came = false;
1623 if let Some((here, seal, Some(vault))) = sealed() {
1624 came = if phone() { heavy_step(&here, &seal, &vault, sioul_core::reach::region(&load_config())) } else { fetch_media(&seal, &vault) };
1625 }
1626 WORKING.store(false, Ordering::Release);
1627 if came && let Some(qt) = qt {
1628 let store = if phone() { "data/texts/log/" } else { "data/texts/media/" };
1629 let _ = qt.queue(move |mut sioul| sioul.as_mut().shared_in(QString::from(store)));
1630 }
1631 });
1632 }
1633 let now = now_ms();
1634 let due = TRIMMED.lock().map(|mut last| {
1635 let due = now - *last > 86_400_000;
1636 if due {
1637 *last = now;
1638 }
1639 due
1640 });
1641 if due.unwrap_or(false) {
1642 let done = rules::trim_own(&root(), &here, &seal, now).and_then(|_| rules::trim_others(&root(), &here, &seal, now));
1643 if phone() {
1644 let _ = rules::trim_ledger(&private(), now);
1645 }
1646 if let Err(e) = done {
1647 eprintln!("sioul: texts: {e}");
1648 }
1649 }
1650}
1651
1652fn media_path(hash: &str) -> PathBuf {
1656 root().join(rules::MEDIA).join(hash.chars().filter(char::is_ascii_hexdigit).collect::<String>())
1657}
1658
1659fn here_media(hash: &str) -> bool {
1660 !hash.is_empty() && media_path(hash).exists()
1661}
1662
1663static FETCHED: Mutex<(Vec<(u64, u128)>, bool)> = Mutex::new((Vec::new(), true));
1666
1667fn fetch_media(seal: &Seal, vault: &Vault) -> bool {
1672 let (folder, key) = vault;
1673 let mut logs: Vec<(u64, u128)> = std::fs::read_dir(root().join(rules::LOG))
1674 .into_iter()
1675 .chain(std::fs::read_dir(root().join(rules::SEND)))
1676 .flatten()
1677 .filter_map(|entry| entry.ok()?.metadata().ok())
1678 .map(|m| (m.len(), m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos())))
1679 .collect();
1680 logs.sort_unstable();
1681 if FETCHED.lock().is_ok_and(|seen| seen.0 == logs && !seen.1) {
1682 return false;
1683 }
1684 let mut wanted: Vec<(String, u64)> = rules::read_logs(&root(), seal).iter().flat_map(|t| t.parts.iter()).filter(|p| p.state == "here" && !p.hash.is_empty() && !here_media(&p.hash)).map(|p| (p.hash.clone(), p.size)).collect();
1685 let since = now_ms() - DAY_MS;
1688 wanted.extend(rules::read_requests(&root(), seal).iter().filter(|r| r.written >= since).flat_map(|r| r.media.iter()).filter(|m| !here_media(&m.hash)).map(|m| (m.hash.clone(), m.size)));
1689 wanted.sort();
1690 wanted.dedup();
1691 let mut budget = rules::MEDIA_BATCH;
1692 let (mut brought, mut left) = (0, false);
1693 let asking = crate::share::keeps_itself();
1697 let (mut ask, mut have): (Vec<(String, String, u64)>, Vec<String>) = (Vec::new(), Vec::new());
1698 for (hash, size) in wanted {
1699 if size > budget && budget < rules::MEDIA_BATCH {
1700 left = true;
1701 break;
1702 }
1703 let incoming = private().join("incoming").join(&hash);
1704 if let Some(dir) = incoming.parent() {
1705 let _ = std::fs::create_dir_all(dir);
1706 }
1707 if sioul_sync::blobs::get(folder, key, &hash, &incoming).is_err() {
1708 if asking {
1709 ask.push((sioul_sync::blobs::name(key, &hash), hash.clone(), size));
1710 }
1711 left = true;
1712 continue;
1713 }
1714 if asking {
1715 have.push(sioul_sync::blobs::name(key, &hash));
1716 }
1717 let bytes = std::fs::read(&incoming);
1718 let _ = std::fs::remove_file(&incoming);
1719 let Ok(bytes) = bytes else {
1720 left = true;
1721 continue;
1722 };
1723 if let Err(e) = write_whole(&media_path(&hash), &seal.seal_bytes(&bytes)) {
1724 eprintln!("sioul: texts: {e}");
1725 left = true;
1726 break;
1727 }
1728 budget = budget.saturating_sub(bytes.len() as u64);
1729 brought += 1;
1730 }
1731 if asking && (!ask.is_empty() || !have.is_empty()) {
1732 let now = jiff::Timestamp::now().as_second();
1733 let _ = sioul_sync::blobs::Wanted::change(&crate::share::memory_path(), |wanted| {
1734 for name in &have {
1735 wanted.media.remove(name);
1736 }
1737 for (name, hash, size) in ask {
1738 wanted.media.entry(name).or_insert_with(|| sioul_sync::blobs::Want { hash, size, since: now, ..Default::default() });
1739 }
1740 });
1741 }
1742 if let Ok(mut seen) = FETCHED.lock() {
1743 *seen = (logs, left);
1744 }
1745 if brought > 0 {
1746 eprintln!("sioul: texts: {brought} media file(s) brought");
1747 }
1748 brought > 0
1749}
1750
1751fn ask_media_again(hash: &str) {
1755 if hash.is_empty() || !crate::share::keeps_itself() {
1756 return;
1757 }
1758 let Some((_, _, Some((_, key)))) = sealed() else { return };
1759 let name = sioul_sync::blobs::name(&key, hash);
1760 let now = jiff::Timestamp::now().as_second();
1761 let _ = sioul_sync::blobs::Wanted::change(&crate::share::memory_path(), |wanted| {
1762 if let Some(want) = wanted.media.get_mut(&name)
1763 && now - want.since >= 3 * 86_400
1764 {
1765 (want.since, want.again, want.tries) = (now, 0, 0);
1766 }
1767 });
1768}
1769
1770fn opened_folder() -> PathBuf {
1772 sioul_core::config::cache_dir().join(rules::FOLDER)
1773}
1774
1775fn open_media(hash: &str, ct: &str) -> Option<PathBuf> {
1777 let path = media_path(hash);
1778 let extension = ct.split('/').nth(1).and_then(|s| s.split(['+', ';', '.', '-']).next()).filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin");
1779 let target = opened_folder().join(format!("{}.{extension}", path.file_name()?.to_string_lossy()));
1780 if target.exists() {
1782 return Some(target);
1783 }
1784 let (_, seal, _) = sealed()?;
1785 let plain = seal.open_bytes(&std::fs::read(&path).ok()?)?;
1786 write_whole(&target, &plain).ok()?;
1787 Some(target)
1788}
1789
1790#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1796struct Clock {
1797 #[serde(default)]
1798 phones: BTreeMap<String, Gap>,
1799}
1800
1801#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1802struct Gap {
1803 seen: i64,
1805 least: i64,
1807 at: i64,
1808}
1809
1810impl Clock {
1811 fn path() -> PathBuf {
1812 private().join("clock.json")
1813 }
1814}
1815
1816fn measure_clock() {
1821 static LOOKED: Mutex<i64> = Mutex::new(0);
1823 let now = now_ms();
1824 if let Ok(mut looked) = LOOKED.lock() {
1825 if now - *looked < 30_000 {
1826 return;
1827 }
1828 *looked = now;
1829 }
1830 let Some(phone) = sending_phone() else { return };
1831 let mut clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
1832 let gap = clock.phones.entry(phone.id.clone()).or_default();
1833 if phone.exported <= 0 || phone.exported == gap.seen {
1834 return;
1835 }
1836 let sample = now - phone.exported * 1000;
1837 gap.seen = phone.exported;
1838 if gap.at == 0 || now - gap.at > 86_400_000 || sample < gap.least {
1839 gap.least = sample;
1840 gap.at = now;
1841 }
1842 if let Ok(text) = serde_json::to_string(&clock) {
1843 let _ = write_whole(&Clock::path(), text.as_bytes());
1844 }
1845}
1846
1847pub(crate) fn ahead_of(phone: &str) -> i64 {
1849 let clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
1850 clock.phones.get(phone).filter(|g| now_ms() - g.at <= 86_400_000).map_or(0, |g| g.least.max(0))
1851}
1852
1853pub(crate) fn phones() -> Vec<sioul_sync::devices::Entry> {
1857 let Some((_, Some((folder, key)))) = crate::share::vault() else { return Vec::new() };
1858 let mut all: Vec<sioul_sync::devices::Entry> = sioul_sync::devices::all(&folder, &key).0.into_iter().filter(|d| d.kind == sioul_sync::devices::PHONE).collect();
1859 let chosen = choice();
1860 all.sort_by_key(|d| (d.id != chosen.phone, -d.exported));
1861 all
1862}
1863
1864fn sending_phone() -> Option<sioul_sync::devices::Entry> {
1865 if demo() && !phone() {
1866 let exported = jiff::Timestamp::now().as_second() - 120;
1868 return Some(sioul_sync::devices::Entry { id: "demo-phone".into(), name: String::new(), kind: sioul_sync::devices::PHONE.into(), exported, ..Default::default() });
1869 }
1870 phones().into_iter().next()
1871}
1872
1873fn names(senders: std::rc::Rc<Senders>) -> impl Fn(&str) -> Option<String> {
1876 move |key: &str| {
1877 let judged = senders.judge_number(key);
1878 (!judged.card.trim().is_empty()).then_some(judged.card)
1879 }
1880}
1881
1882thread_local! {
1883 static LOOKS: std::cell::RefCell<Option<(Option<(u64, std::time::SystemTime)>, std::time::Instant, std::rc::Rc<Config>, std::rc::Rc<Senders>)>> = const { std::cell::RefCell::new(None) };
1886}
1887
1888fn looks() -> (std::rc::Rc<Config>, std::rc::Rc<Senders>) {
1891 let stamp = std::fs::metadata(config_dir().join("config.toml")).ok().map(|m| (m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
1892 LOOKS.with(|cell| {
1893 let mut kept = cell.borrow_mut();
1894 if let Some((s, at, config, senders)) = kept.as_ref()
1895 && *s == stamp
1896 && at.elapsed() < std::time::Duration::from_secs(60)
1897 {
1898 return (std::rc::Rc::clone(config), std::rc::Rc::clone(senders));
1899 }
1900 let config = std::rc::Rc::new(load_config());
1901 let senders = std::rc::Rc::new(Senders::load(&config));
1902 *kept = Some((stamp, std::time::Instant::now(), std::rc::Rc::clone(&config), std::rc::Rc::clone(&senders)));
1903 (config, senders)
1904 })
1905}
1906
1907struct Kept {
1917 here: String,
1918 logs: rules::Logs,
1919 stamp: Vec<(PathBuf, u64, std::time::SystemTime)>,
1920 region: Option<&'static str>,
1921 texts: std::sync::Arc<Vec<Text>>,
1922 requests: std::sync::Arc<Vec<Request>>,
1923 outcomes: std::sync::Arc<Vec<Outcome>>,
1924 marks: rules::Marks,
1926 visible: std::sync::Arc<Vec<Text>>,
1927}
1928
1929static KEPT: Mutex<Option<Kept>> = Mutex::new(None);
1930
1931fn stamps(root: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
1933 let mut all = Vec::new();
1934 for store in [rules::SEND, rules::OUTCOME] {
1935 for entry in std::fs::read_dir(root.join(store)).into_iter().flatten().flatten() {
1936 if let Ok(meta) = entry.metadata() {
1937 all.push((entry.path(), meta.len(), meta.modified().unwrap_or(std::time::UNIX_EPOCH)));
1938 }
1939 }
1940 }
1941 all.sort();
1942 all
1943}
1944
1945#[allow(clippy::type_complexity)]
1951fn records(here: &str, seal: &Seal) -> (std::sync::Arc<Vec<Text>>, std::sync::Arc<Vec<Request>>, std::sync::Arc<Vec<Outcome>>) {
1952 let root = root();
1953 let stamp = stamps(&root);
1954 let region = sioul_core::reach::region(&looks().0);
1955 let mut kept = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1956 let k = match kept.take() {
1957 Some(k) if k.here == here => kept.insert(k),
1958 _ => kept.insert(Kept {
1959 here: here.to_string(),
1960 logs: rules::Logs::default(),
1961 stamp: Vec::new(),
1962 region: None,
1963 texts: std::sync::Arc::default(),
1964 requests: std::sync::Arc::default(),
1965 outcomes: std::sync::Arc::default(),
1966 marks: rules::Marks::default(),
1967 visible: std::sync::Arc::default(),
1968 }),
1969 };
1970 let first = k.stamp.is_empty() && k.texts.is_empty() && k.requests.is_empty();
1971 let mut texts_changed = first;
1972 if k.logs.refresh(&root, seal) || k.region != region.map(|r| r.code) {
1973 k.texts = std::sync::Arc::new(k.logs.one_each(region));
1975 k.region = region.map(|r| r.code);
1976 texts_changed = true;
1977 }
1978 let marks = rules::Marks::read_all(&root, seal);
1980 if texts_changed || marks.hidden != k.marks.hidden {
1981 k.visible = if marks.hidden.is_empty() { std::sync::Arc::clone(&k.texts) } else { std::sync::Arc::new(rules::visible(&k.texts, &marks)) };
1982 }
1983 k.marks = marks;
1984 if first || k.stamp != stamp {
1985 k.requests = std::sync::Arc::new(rules::read_requests(&root, seal));
1986 k.outcomes = std::sync::Arc::new(rules::read_outcomes(&root, seal));
1987 k.stamp = stamp;
1988 }
1989 let requests = if k.marks.hidden.is_empty() {
1990 std::sync::Arc::clone(&k.requests)
1991 } else {
1992 let hidden = &k.marks.hidden;
1994 std::sync::Arc::new(k.requests.iter().filter(|r| hidden.get(&phones::key(&r.to, region)).is_none_or(|upto| r.written > *upto)).cloned().collect())
1995 };
1996 (std::sync::Arc::clone(&k.visible), requests, std::sync::Arc::clone(&k.outcomes))
1997}
1998
1999fn marked(here: &str, seal: &Seal) -> (rules::Marks, std::sync::Arc<Vec<Text>>) {
2002 let _ = records(here, seal);
2003 let kept = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2004 kept.as_ref().map_or_else(Default::default, |k| (k.marks.clone(), std::sync::Arc::clone(&k.texts)))
2005}
2006
2007pub(crate) fn notices() -> Value {
2011 if phone() || !readable() {
2012 return json!([]);
2013 }
2014 let Some((here, seal, _)) = sealed() else { return json!([]) };
2015 let (config, senders) = looks();
2016 let now = Zoned::now();
2017 let name_of = names(senders);
2018 let region = sioul_core::reach::region(&config);
2019 let (texts, _, _) = records(&here, &seal);
2020 let (marks, _) = marked(&here, &seal);
2021 let mut list: Vec<(i64, Value)> = rules::unread(&texts, &marks, now.timestamp().as_millisecond())
2022 .into_iter()
2023 .map(|(id, unread)| {
2024 let newest = unread[0];
2025 let who = newest.with.iter().map(|k| name_of(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, region))).collect::<Vec<_>>().join(", ");
2026 let first: String = if newest.body.is_empty() { tr().text("phonemsgs-picture", None) } else { newest.body.chars().take(80).collect() };
2027 (newest.at, json!({ "conversation": id, "who": who, "first": first, "when": rules::time_of_day(newest.at, &now), "count": unread.len() }))
2028 })
2029 .collect();
2030 list.sort_by(|a, b| b.0.cmp(&a.0));
2031 Value::Array(list.into_iter().map(|(_, v)| v).collect())
2032}
2033
2034pub(crate) fn folding() -> Option<impl Fn(&str, i64) -> bool> {
2039 if phone() || !readable() {
2040 return None;
2041 }
2042 let (here, seal, _) = sealed()?;
2043 let (_, all) = marked(&here, &seal);
2044 Some(move |key: &str, at_ms: i64| rules::folded_into(key, at_ms, &all).is_some())
2045}
2046
2047fn mark_read(here: &str, seal: &Seal, id: &str, texts: &[Text]) {
2050 let newest = texts.iter().filter(|t| t.direction == "in" && rules::conversation_id(&t.with) == id).map(|t| t.at).max().unwrap_or(0);
2051 let read = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|k| k.marks.read.get(id).copied()).unwrap_or(0);
2052 if newest > read {
2053 let mark = rules::TextMark { at: now_ms(), conversation: id.to_string(), read: newest, ..rules::TextMark::default() };
2054 if let Err(e) = rules::write_mark(&root(), here, &mark, seal) {
2055 eprintln!("sioul: texts: {e}");
2056 }
2057 }
2058 crate::phonemsgs::seen_for(&id.split(',').map(str::to_string).collect::<Vec<_>>());
2059}
2060
2061fn delete(id: &str) -> Value {
2068 let Some((here, seal, _)) = sealed() else { return json!({ "said": tr().text("texts-page-off", None) }) };
2069 let (texts, requests, _) = records(&here, &seal);
2070 let region = sioul_core::reach::region(&looks().0);
2071 let upto = texts
2072 .iter()
2073 .filter(|t| rules::conversation_id(&t.with) == id)
2074 .map(|t| t.at)
2075 .chain(requests.iter().filter(|r| phones::key(&r.to, region) == id).map(|r| r.written))
2076 .max()
2077 .unwrap_or(0)
2078 .max(now_ms());
2079 let mark = rules::TextMark { at: now_ms(), conversation: id.to_string(), read: upto, hidden: upto };
2080 if let Err(e) = rules::write_mark(&root(), &here, &mark, &seal) {
2081 return json!({ "said": e });
2082 }
2083 crate::phonemsgs::seen_for(&id.split(',').map(str::to_string).collect::<Vec<_>>());
2084 forget_hidden_media(&here, &seal);
2085 json!({ "said": "" })
2086}
2087
2088fn forget_hidden_media(here: &str, seal: &Seal) {
2090 let (marks, all) = marked(here, seal);
2091 let shown: BTreeSet<&str> = all.iter().filter(|t| !marks.hides(t)).flat_map(|t| t.parts.iter().map(|p| p.hash.as_str())).collect();
2092 for hash in all.iter().filter(|t| marks.hides(t)).flat_map(|t| t.parts.iter().map(|p| p.hash.as_str())).filter(|h| !h.is_empty() && !shown.contains(h)) {
2093 let _ = std::fs::remove_file(media_path(hash));
2094 }
2095}
2096
2097fn view(query: &str) -> Value {
2104 let tr = tr();
2105 let Some((here, seal, _)) = sealed() else { return json!({ "can": false, "said": tr.text("texts-page-off", None), "conversations": [] }) };
2106 let Some(phone) = sending_phone() else { return json!({ "can": false, "said": tr.text("texts-page-no-phone", None), "conversations": [] }) };
2107 let (config, senders) = looks();
2108 let now = Zoned::now();
2109 let name_of = names(senders);
2110 let shared = (phone.exported > 0).then_some(phone.exported * 1000);
2111 let v = rules::Viewer { now: &now, tr, region: sioul_core::reach::region(&config), name_of: &name_of, phone_shared: shared, media_here: &here_media };
2112 let (texts, requests, _) = records(&here, &seal);
2113 let mut conversations = rules::conversations(&texts, &requests, &v);
2114 let query = query.trim();
2115 if !query.is_empty() {
2116 let mut found: BTreeMap<String, String> = BTreeMap::new();
2118 for f in rules::search(query, &texts, &requests, &[], &v, usize::MAX) {
2119 found.entry(f.conversation).or_insert_with(|| f.message.body.chars().take(80).collect());
2120 }
2121 conversations.retain(|c| found.contains_key(&c.id));
2122 for c in &mut conversations {
2123 if let Some(words) = found.get(&c.id).filter(|w| !w.is_empty()) {
2124 c.last = words.clone();
2125 }
2126 }
2127 }
2128 let mut rows = with_drafts(conversations, &sioul_core::textdraft::all(&seal), &v);
2129 let (marks, _) = marked(&here, &seal);
2131 let unread = rules::unread(&texts, &marks, now.timestamp().as_millisecond());
2132 if let Value::Array(list) = &mut rows {
2133 for row in list.iter_mut() {
2134 let id = row["id"].as_str().unwrap_or_default().to_string();
2135 row["unread"] = json!(unread.get(&id).map_or(0, Vec::len));
2136 }
2137 }
2138 if !query.is_empty()
2139 && let Value::Array(list) = &mut rows
2140 {
2141 let words = sioul_core::rules::fold(query);
2143 list.retain(|row| row["at"].as_i64().unwrap_or(0) > 0 || words.split(' ').all(|w| sioul_core::rules::fold(&format!("{} {}", row["title"].as_str().unwrap_or(""), row["id"].as_str().unwrap_or(""))).contains(w)));
2144 }
2145 let shared_at = shared.map(|ms| rules::time_of_day(ms, &now)).unwrap_or_default();
2146 let mut args = sioul_core::i18n::args();
2147 args.set("time", shared_at.clone());
2148 json!({
2149 "can": true,
2150 "phone": { "id": phone.id, "name": phone.name, "shared": shared_at, "said": if shared_at.is_empty() { String::new() } else { tr.text("texts-page-shared", Some(&args)) } },
2151 "conversations": rows,
2152 "said": "",
2153 "never": tr.text("texts-page-never", None),
2154 "empty": tr.text(if query.is_empty() { "texts-page-empty" } else { "texts-search-none" }, None),
2155 })
2156}
2157
2158fn with_drafts(conversations: Vec<rules::Conversation>, drafts: &[sioul_core::textdraft::TextDraft], v: &rules::Viewer) -> Value {
2162 let waiting = |id: &str| drafts.iter().filter(|d| d.conversation == id).count();
2163 let mut out: Vec<Value> = Vec::new();
2164 let mut new: Vec<&str> = drafts.iter().map(|d| d.conversation.as_str()).filter(|id| !conversations.iter().any(|c| c.id == *id)).collect();
2165 new.sort_unstable();
2166 new.dedup();
2167 for id in new {
2168 let title = id.split(',').map(|k| (v.name_of)(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ");
2169 out.push(json!({ "id": id, "with": [id], "title": title, "last": "", "when": "", "at": 0, "group": false, "drafts": waiting(id) }));
2170 }
2171 for c in conversations {
2172 let mut row = serde_json::to_value(&c).unwrap_or_default();
2173 row["drafts"] = json!(waiting(&c.id));
2174 out.push(row);
2175 }
2176 Value::Array(out)
2177}
2178
2179fn conversation(id: &str) -> Value {
2183 let Some((here, seal, _)) = sealed() else { return json!({ "messages": [] }) };
2184 let (config, senders) = looks();
2185 let now = Zoned::now();
2186 let name_of = names(senders);
2187 let phone = sending_phone();
2188 let shared = phone.as_ref().filter(|p| p.exported > 0).map(|p| p.exported * 1000);
2189 let region = sioul_core::reach::region(&config);
2190 let v = rules::Viewer { now: &now, tr: tr(), region, name_of: &name_of, phone_shared: shared, media_here: &here_media };
2191 let (texts, requests, outcomes) = records(&here, &seal);
2192 let messages = rules::messages(id, &texts, &requests, &outcomes, &v);
2193 mark_read(&here, &seal, id, &texts);
2195 let with: Vec<String> = id.split(',').map(str::to_string).collect();
2196 let title = with.iter().map(|k| name_of(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, region))).collect::<Vec<_>>().join(", ");
2197 let drafts: Vec<Value> = sioul_core::textdraft::all(&seal).into_iter().filter(|d| d.conversation == id).map(|d| json!({ "id": d.id, "body": d.body, "when": rules::time_of_day(d.written, &now) })).collect();
2199 let attach = attach_limit(phone.as_ref(), id);
2200 json!({
2201 "id": id,
2202 "title": title,
2203 "messages": messages,
2204 "drafts": drafts,
2205 "can_write": with.len() == 1,
2206 "group": with.len() > 1,
2207 "attach": attach.is_ok(),
2208 "attach_said": attach.err().map_or(String::new(), |key| tr().text(key, None)),
2209 })
2210}
2211
2212fn draft_taken(id: &str) -> Value {
2215 let Some((_, seal, _)) = sealed() else { return json!({ "body": "" }) };
2216 let body = sioul_core::textdraft::all(&seal).into_iter().find(|d| d.id == id).map(|d| d.body).unwrap_or_default();
2217 let _ = sioul_core::textdraft::remove(id, &seal);
2218 json!({ "body": body })
2219}
2220
2221fn send(to: &str, body: &str, again: &str, from: &str) -> (Value, bool) {
2225 let tr = tr();
2226 let Some((here, seal, _)) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
2227 let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2229 let Some(phone) = phone else { return (json!({ "said": tr.text("texts-page-no-phone", None), "shared": false }), false) };
2230 let region = sioul_core::reach::region(&load_config());
2231 if let Some(why) = rules::refusal(to, body, region) {
2232 return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
2233 }
2234 let request = Request { key: rules::new_key(), phone: phone.id.clone(), to: to.trim().to_string(), body: body.to_string(), sub: -1, written: now_ms(), ahead: ahead_of(&phone.id), again: again.to_string(), ..Request::default() };
2235 match rules::write_request(&root(), &here, &request, &seal) {
2236 Ok(_) => {
2237 crate::share::texts_now();
2239 (json!({ "said": "", "shared": true, "key": request.key }), true)
2240 }
2241 Err(e) => (json!({ "said": e, "shared": false }), false),
2242 }
2243}
2244
2245fn again(key: &str) -> (Value, bool) {
2248 let Some((_, seal, _)) = sealed() else { return (json!({ "said": tr().text("texts-page-off", None), "shared": false }), false) };
2249 match rules::read_requests(&root(), &seal).into_iter().find(|r| r.key == key) {
2250 Some(old) if old.multimedia() => request_mms(&old.to, &old.words, old.media, key, &old.phone),
2251 Some(old) => send(&old.to, &old.body, key, &old.phone),
2252 None => (json!({ "said": "", "shared": false }), false),
2253 }
2254}
2255
2256pub(crate) fn send_mms(to: &str, words: &str, media: &[(String, Vec<u8>)], from: &str) -> (Value, bool) {
2265 let tr = tr();
2266 let Some((_, seal, Some((folder, key)))) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
2267 let staging = private().join("staging");
2268 let mut named: Vec<rules::Medium> = Vec::new();
2269 for (i, (ct, bytes)) in media.iter().enumerate() {
2270 let source = staging.join(i.to_string());
2271 let hashed = write_whole(&source, bytes).and_then(|()| sioul_sync::blobs::hash_file(&source).map_err(|e| format!("{}: {e}", source.display())));
2272 let _ = std::fs::remove_file(&source);
2273 match hashed {
2274 Ok((hash, size)) => named.push(rules::Medium { hash, ct: ct.clone(), size }),
2275 Err(e) => return (json!({ "said": e, "shared": false }), false),
2276 }
2277 }
2278 let region = sioul_core::reach::region(&load_config());
2279 if let Some(why) = rules::refusal_mms(to, words, &named, &[], region) {
2280 return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
2281 }
2282 for ((_, bytes), medium) in media.iter().zip(&named) {
2283 let source = staging.join(&medium.hash);
2284 let sealed_there = write_whole(&source, bytes).and_then(|()| sioul_sync::blobs::put(&folder, &key, &source, &medium.hash).map(|_| ()));
2285 let _ = std::fs::remove_file(&source);
2286 if let Err(e) = sealed_there {
2287 return (json!({ "said": e, "shared": false }), false);
2288 }
2289 if !here_media(&medium.hash)
2290 && let Err(e) = write_whole(&media_path(&medium.hash), &seal.seal_bytes(bytes))
2291 {
2292 eprintln!("sioul: texts: {e}");
2293 }
2294 }
2295 request_mms(to, words, named, "", from)
2296}
2297
2298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2300enum Weigh {
2301 Unknown,
2304 None,
2306 Up { max: u64, side: u32 },
2308}
2309
2310const LOW_KB: u64 = 300;
2312
2313fn weigh(phone: &str, id: &str) -> Weigh {
2318 let Some((here, seal, _)) = sealed() else { return Weigh::Unknown };
2319 let (texts, _, outcomes) = records(&here, &seal);
2320 weighed(rules::limits_of(&outcomes, phone, now_ms()).map(|(_, limits)| limits), rules::last_sim(id, phone, &texts), choice().mms_kb)
2321}
2322
2323fn weighed(limits: Option<&[rules::Limits]>, sim: i32, chosen_kb: Option<u64>) -> Weigh {
2329 let Some(limits) = limits else { return Weigh::Unknown };
2330 let carrier = limits.iter().find(|l| sim >= 0 && l.sub == sim).or_else(|| limits.iter().filter(|l| l.mms).min_by_key(|l| l.max)).or_else(|| limits.first());
2331 match carrier {
2332 None => Weigh::Unknown,
2333 Some(l) if !l.mms => Weigh::None,
2334 Some(l) if l.max == 0 => Weigh::Unknown,
2336 Some(l) => Weigh::Up { max: chosen_kb.map_or(l.max, |kb| (kb * 1024).min(l.max)).min(rules::MMS_MAX_EVER), side: l.width.max(l.height) },
2338 }
2339}
2340
2341fn attached_folder() -> PathBuf {
2343 sioul_core::config::cache_dir().join("texts-attached")
2344}
2345
2346fn made_in(folder: &Path, path: &Path) -> bool {
2349 match (std::fs::canonicalize(folder), std::fs::canonicalize(path)) {
2350 (Ok(folder), Ok(path)) => path.starts_with(&folder) && path != folder && path.is_file(),
2351 _ => false,
2352 }
2353}
2354
2355static ATTACHING: Mutex<()> = Mutex::new(());
2357static ATTACH_TURN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2361
2362#[cfg(not(target_os = "android"))]
2366pub(crate) fn at_start() {
2367 let_attached_go();
2368}
2369
2370fn let_attached_go() {
2372 ATTACH_TURN.fetch_add(1, Ordering::SeqCst);
2373 let _ = std::fs::remove_dir_all(attached_folder());
2374}
2375
2376pub(crate) fn attach(json: &str) -> String {
2383 let asked: Value = serde_json::from_str(json).unwrap_or_default();
2384 let tr = tr();
2385 let round = asked["round"].as_i64().unwrap_or(0);
2386 let id = asked["id"].as_str().unwrap_or_default().to_string();
2387 let from = asked["phone"].as_str().unwrap_or_default();
2388 let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2389 let sources: Vec<PathBuf> = asked["sources"].as_array().map(|all| all.iter().filter_map(|s| s.as_str()).map(crate::backend::local_path).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default();
2390 let turn = ATTACH_TURN.fetch_add(1, Ordering::SeqCst) + 1;
2391 let (max, side) = match attach_limit(phone.as_ref(), &id) {
2392 Ok(limit) => limit,
2393 Err(key) => return json!({ "round": round, "media": [], "said": [tr.text(key, None)], "limit": "", "max": 0 }).to_string(),
2394 };
2395 let _one = ATTACHING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2396 let passed = || ATTACH_TURN.load(Ordering::SeqCst) != turn;
2397 if passed() {
2398 return json!({ "round": round, "media": [], "said": [], "limit": "", "max": 0 }).to_string();
2399 }
2400 let made = attach_made(&sources, max, side, round);
2401 if passed() {
2403 let _ = std::fs::remove_dir_all(attached_folder());
2404 }
2405 made
2406}
2407
2408fn attach_limit(phone: Option<&sioul_sync::devices::Entry>, id: &str) -> Result<(u64, u32), &'static str> {
2414 let Some(phone) = phone else { return Err("texts-page-no-phone") };
2415 if id.contains(',') {
2416 return Err("texts-group-read-only");
2417 }
2418 match weigh(&phone.id, id) {
2419 Weigh::Unknown => Err("texts-attach-gate"),
2420 Weigh::None => Err("texts-sending-no-mms"),
2421 Weigh::Up { max, side } => Ok((max, side)),
2422 }
2423}
2424
2425#[cfg(not(target_os = "android"))]
2427fn attach_made(sources: &[PathBuf], max: u64, side: u32, round: i64) -> String {
2428 let tr = tr();
2429 let folder = attached_folder();
2430 let _ = std::fs::remove_dir_all(&folder);
2431 let (made, left) = crate::attach::make(sources, max, side, &folder);
2432 let media: Vec<Value> = made
2433 .iter()
2434 .map(|m| {
2435 let mut args = sioul_core::i18n::args();
2436 args.set("size", size_words(m.size));
2437 json!({ "source": m.source, "kind": m.kind, "ct": m.ct, "path": m.path, "url": crate::backend::file_url(Path::new(&m.path)), "size": m.size, "said": tr.text(&format!("texts-attached-{}", m.kind), Some(&args)) })
2439 })
2440 .collect();
2441 let said: Vec<String> = left
2442 .iter()
2443 .map(|l| {
2444 let mut args = sioul_core::i18n::args();
2445 args.set("name", Path::new(&l.source).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default());
2446 args.set("size", size_words(l.size));
2447 args.set("limit", size_words(max));
2448 args.set("kind", l.format);
2449 tr.text(&format!("texts-attach-left-{}", l.why), Some(&args))
2450 })
2451 .collect();
2452 json!({ "round": round, "media": media, "said": said, "limit": size_words(max), "max": max }).to_string()
2453}
2454
2455#[cfg(target_os = "android")]
2457fn attach_made(_: &[PathBuf], _: u64, _: u32, round: i64) -> String {
2458 json!({ "round": round, "media": [], "said": [tr().text("texts-refused-other", None)], "limit": "", "max": 0 }).to_string()
2459}
2460
2461fn parts_mms(body: &str, media: &[Value], id: &str, from: &str) -> Value {
2465 let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2466 let max = match phone.map(|p| weigh(&p.id, id)) {
2467 Some(Weigh::Up { max, .. }) => max,
2468 _ => 0,
2469 };
2470 let cts: Vec<String> = media.iter().map(|m| m["ct"].as_str().unwrap_or("application/octet-stream").to_string()).collect();
2471 let lengths: Vec<usize> = media.iter().map(|m| m["size"].as_u64().unwrap_or(0) as usize).collect();
2472 let empty: Vec<sioul_core::mms::Medium> = cts.iter().map(|ct| sioul_core::mms::Medium { ct, bytes: &[] }).collect();
2473 let key = "0".repeat(32);
2474 let message = sioul_core::mms::Message { key: &key, to: &[sioul_core::mms::LONGEST_NUMBER], date: jiff::Timestamp::now().as_second(), words: body, media: &empty };
2476 let size = sioul_core::mms::size_with_lengths(&message, &lengths).unwrap_or(0) as u64;
2477 let mut args = sioul_core::i18n::args();
2478 args.set("size", size_words(size));
2479 args.set("limit", size_words(max));
2480 json!({ "parts": 1, "said": tr().text("texts-parts-mms", Some(&args)), "over": max > 0 && size > max, "max": max })
2481}
2482
2483fn sending() -> Value {
2487 let tr = tr();
2488 let phone = sending_phone();
2489 let limits = sealed().map(|(here, seal, _)| records(&here, &seal).2).and_then(|outcomes| phone.as_ref().and_then(|p| rules::limits_of(&outcomes, &p.id, now_ms()).map(|(_, l)| l.to_vec()))).unwrap_or_default();
2490 let carrier = limits.iter().filter(|l| l.mms).map(|l| l.max).min().unwrap_or(614_400).min(rules::MMS_MAX_EVER);
2492 let size = |bytes: u64| {
2493 let mut args = sioul_core::i18n::args();
2494 args.set("size", size_words(bytes));
2495 args
2496 };
2497 let lines = limit_lines(&limits);
2498 let choices = vec![json!({ "value": "carrier", "label": tr.text("texts-sending-limit-carrier", Some(&size(carrier))) }), json!({ "value": "300", "label": tr.text("texts-sending-limit-low", Some(&size(LOW_KB * 1024))) })];
2499 json!({
2500 "limit": { "key": "limit", "kind": "choice", "label": tr.text("texts-sending-limit", None), "help": tr.text("texts-sending-limit-help", None), "value": if choice().mms_kb.is_some() { "300" } else { "carrier" }, "choices": choices },
2501 "phone": lines,
2502 })
2503}
2504
2505fn limit_lines(limits: &[rules::Limits]) -> Vec<String> {
2509 let tr = tr();
2510 let size = |bytes: u64| {
2511 let mut args = sioul_core::i18n::args();
2512 args.set("size", size_words(bytes));
2513 args
2514 };
2515 let pictures = |l: &rules::Limits| {
2517 if l.width == 0 || l.height == 0 {
2518 return String::new();
2519 }
2520 let mut args = sioul_core::i18n::args();
2521 args.set("width", i64::from(l.width));
2522 args.set("height", i64::from(l.height));
2523 format!(" {}", tr.text("texts-sending-pictures", Some(&args)))
2524 };
2525 match limits {
2526 [] => vec![tr.text("texts-sending-phone-none", None)],
2527 [one] if one.mms => vec![tr.text("texts-sending-phone", Some(&size(one.max))) + &pictures(one)],
2528 [_] => vec![tr.text("texts-sending-no-mms", None)],
2529 several => several
2530 .iter()
2531 .enumerate()
2532 .map(|(n, l)| {
2533 let mut args = size(l.max);
2534 args.set("sim", i64::try_from(n + 1).unwrap_or(0));
2535 if l.mms { tr.text("texts-sending-phone-sim", Some(&args)) + &pictures(l) } else { tr.text("texts-sending-no-mms-sim", Some(&args)) }
2536 })
2537 .collect(),
2538 }
2539}
2540
2541fn set_limit(value: &str) -> Result<(), String> {
2543 let path = choice_path();
2544 sioul_core::filelock::with_lock(&path, || {
2545 let mut chosen = choice();
2546 chosen.mms_kb = (value.trim() == "300").then_some(LOW_KB);
2547 write_whole(&path, toml::to_string(&chosen).map_err(|e| e.to_string())?.as_bytes())
2548 })
2549}
2550
2551fn request_mms(to: &str, words: &str, media: Vec<rules::Medium>, again: &str, from: &str) -> (Value, bool) {
2553 let tr = tr();
2554 let Some((here, seal, _)) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
2555 let phone = if from.is_empty() || (demo() && !phone()) { sending_phone() } else { phones().into_iter().find(|p| p.id == from) };
2556 let Some(phone) = phone else { return (json!({ "said": tr.text("texts-page-no-phone", None), "shared": false }), false) };
2557 let region = sioul_core::reach::region(&load_config());
2558 if let Some(why) = rules::refusal_mms(to, words, &media, &[], region) {
2559 return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
2560 }
2561 let request = Request { key: rules::new_key(), phone: phone.id.clone(), to: to.trim().to_string(), sub: -1, written: now_ms(), ahead: ahead_of(&phone.id), again: again.to_string(), words: words.to_string(), media, ..Request::default() };
2562 match rules::write_request(&root(), &here, &request, &seal) {
2563 Ok(_) => {
2564 crate::share::texts_now();
2566 (json!({ "said": "", "shared": true, "key": request.key }), true)
2567 }
2568 Err(e) => (json!({ "said": e, "shared": false }), false),
2569 }
2570}
2571
2572pub(crate) fn media_to_send() -> BTreeMap<String, u64> {
2577 let Some((here, seal, Some((folder, key)))) = sealed() else { return BTreeMap::new() };
2578 let now = now_ms();
2579 let mut out = BTreeMap::new();
2580 for r in rules::read_file::<Request>(&rules::own_file(&root(), rules::SEND, &here), &seal) {
2581 if !r.multimedia() || now - r.written > rules::EXPIRY_MS + rules::AHEAD_MS {
2582 continue;
2583 }
2584 for medium in &r.media {
2585 let name = sioul_sync::blobs::name(&key, &medium.hash);
2586 if let Ok(meta) = std::fs::metadata(folder.join("blobs").join(&name)) {
2587 out.insert(name, meta.len());
2588 }
2589 }
2590 }
2591 out
2592}
2593
2594fn parts(body: &str) -> Value {
2596 let (count, gsm) = rules::parts(body);
2597 let mut args = sioul_core::i18n::args();
2598 args.set("count", i64::try_from(count).unwrap_or(0));
2599 args.set("characters", i64::try_from(body.chars().count()).unwrap_or(0));
2600 let said = if count == 0 { String::new() } else { tr().text(if gsm { "texts-parts" } else { "texts-parts-unicode" }, Some(&args)) };
2601 json!({ "parts": count, "said": said })
2602}
2603
2604fn size_words(bytes: u64) -> String {
2606 sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX))
2607}
2608
2609pub(crate) fn kept_words() -> String {
2612 let Some((here, seal, _)) = sealed() else { return String::new() };
2613 let (texts, _, _) = records(&here, &seal);
2614 if texts.is_empty() {
2615 return String::new();
2616 }
2617 let (count, bytes) = rules::kept(&texts, &here_media);
2618 let mut args = sioul_core::i18n::args();
2619 args.set("count", i64::try_from(count).unwrap_or(0));
2620 args.set("size", size_words(bytes));
2621 tr().text("texts-kept", Some(&args))
2622}
2623
2624fn setup() -> Value {
2631 let here = sioul_sync::share::Here::load(&state_dir());
2632 let android = cfg!(target_os = "android");
2633 let state = if android { crate::steps::java("texts-state", "{}") } else { Value::Null };
2634 let mut reader = Reader::load();
2635 if android && state["read"] == true && reader.measured == 0 {
2637 let answer = crate::steps::java("texts-ids", &json!({ "measure": true }).to_string());
2638 if answer["allowed"] == true {
2639 reader.measure(&answer, now_ms());
2640 let _ = reader.save();
2641 }
2642 }
2643 let tr = tr();
2644 let mut args = sioul_core::i18n::args();
2645 args.set("count", i64::try_from(reader.total_texts).unwrap_or(0));
2646 args.set("size", size_words(reader.total_media));
2647 args.set("done", i64::try_from(reader.done_texts).unwrap_or(0));
2648 args.set("done_size", size_words(reader.done_media));
2649 let goes_now = !android || (reader.started > 0 && !reader.behind) || changes(false).1;
2651 let progress = match (reader.measured, reader.done_texts) {
2652 (0, _) => String::new(),
2653 (_, 0) if goes_now => tr.text("texts-setup-holds", Some(&args)),
2654 (_, 0) => format!("{} {}", tr.text("texts-setup-holds", Some(&args)), tr.text("texts-setup-charging", None)),
2655 (_, done) if done >= reader.total_texts => tr.text("texts-setup-done", Some(&args)),
2656 _ if goes_now => tr.text("texts-setup-progress", Some(&args)),
2657 _ => tr.text("texts-setup-progress-charging", Some(&args)),
2658 };
2659 let choices: Vec<Value> = CAPS_MB
2660 .iter()
2661 .map(|mb| {
2662 let mut args = sioul_core::i18n::args();
2663 args.set("size", size_words(mb << 20));
2664 json!({ "value": mb.to_string(), "label": tr.text("texts-setup-cap-value", Some(&args)) })
2665 })
2666 .collect();
2667 json!({
2668 "android": android,
2669 "state": state,
2670 "on": here.shares(rules::PART, &load_config()),
2671 "sharing": here.folder.is_some(),
2672 "progress": progress,
2673 "importing": reader.measured > 0 && reader.done_texts < reader.total_texts,
2674 "guard": guard(&state),
2678 "cap": { "key": "cap", "kind": "choice", "label": tr.text("texts-setup-cap", None), "help": tr.text("texts-setup-cap-help", None), "value": cap_mb().to_string(), "choices": choices },
2679 "limits": if android { limit_lines(&limits_from(&crate::steps::java("texts-limits", "{}"))) } else { Vec::new() },
2681 })
2682}
2683
2684fn guard(state: &Value) -> &'static str {
2691 let given = state["read"] == true && state["receive"] == true && state["send"] == true;
2692 if given || state["asked"] != true {
2693 ""
2694 } else if state["api"].as_i64().unwrap_or(0) >= 35 {
2695 "guarded"
2696 } else if state["at_once"] == true {
2697 "at-once"
2698 } else {
2699 ""
2700 }
2701}
2702
2703fn set_cap(value: &str) -> Result<(), String> {
2705 let mb: u64 = value.trim().parse().map_err(|_| format!("{value}?"))?;
2706 let path = choice_path();
2707 sioul_core::filelock::with_lock(&path, || {
2708 let mut chosen = choice();
2709 chosen.media_cap_mb = (mb != rules::MEDIA_CAP_MB).then_some(mb.clamp(1, sioul_sync::blobs::LARGEST >> 20));
2710 write_whole(&path, toml::to_string(&chosen).map_err(|e| e.to_string())?.as_bytes())
2711 })
2712}
2713
2714pub(crate) fn call(verb: &str, json: &str) -> (String, bool) {
2727 let asked: Value = serde_json::from_str(json).unwrap_or_default();
2728 let text = |key: &str| asked[key].as_str().unwrap_or_default().to_string();
2729 let (answer, shared) = match verb {
2730 "view" => (view(&text("query")), false),
2731 "conversation" => (conversation(&text("id")), false),
2732 "send" => send(&text("to"), &text("body"), "", &text("phone")),
2733 "send-mms" => {
2735 let asked_media = asked["media"].as_array().cloned().unwrap_or_default();
2736 let folder = attached_folder();
2738 let media: Vec<(String, Vec<u8>)> = asked_media
2739 .iter()
2740 .filter_map(|m| {
2741 let path = crate::backend::local_path(m["path"].as_str()?);
2742 made_in(&folder, &path).then_some(())?;
2743 Some((m["ct"].as_str()?.to_string(), std::fs::read(&path).ok()?))
2744 })
2745 .collect();
2746 if media.len() == asked_media.len() {
2747 let sent = send_mms(&text("to"), &text("words"), &media, &text("phone"));
2748 if sent.1 {
2750 let_attached_go();
2751 }
2752 sent
2753 } else {
2754 (json!({ "said": tr().text("texts-refused-other", None), "shared": false }), false)
2755 }
2756 }
2757 "again" => again(&text("key")),
2758 "delete" => {
2759 let done = delete(&text("id"));
2760 let mut tab = view("");
2761 tab["said"] = done["said"].clone();
2762 (tab, true)
2763 }
2764 "parts" => match asked["media"].as_array().filter(|media| !media.is_empty()) {
2765 Some(media) => (parts_mms(&text("body"), media, &text("id"), &text("phone")), false),
2766 None => (parts(&text("body")), false),
2767 },
2768 "sending" => (sending(), false),
2769 "limit" => {
2770 let problem = set_limit(&text("value")).err().unwrap_or_default();
2771 let mut tab = sending();
2772 tab["said"] = json!(problem);
2773 (tab, false)
2774 }
2775 "draft-use" | "draft-discard" => (draft_taken(&text("id")), false),
2777 "media" => {
2778 let url = open_media(&text("hash"), &text("ct")).map(|p| crate::backend::file_url(&p)).unwrap_or_default();
2779 if url.is_empty() {
2780 ask_media_again(&text("hash"));
2781 }
2782 (json!({ "url": url }), false)
2783 }
2784 "save-media" => {
2785 let target = crate::backend::local_path(&text("path"));
2786 let saved = !target.as_os_str().is_empty() && open_media(&text("hash"), &text("ct")).is_some_and(|p| std::fs::copy(p, &target).is_ok());
2787 (json!({ "saved": saved }), false)
2788 }
2789 "close" => {
2790 let _ = std::fs::remove_dir_all(opened_folder());
2791 let_attached_go();
2792 (json!({}), false)
2793 }
2794 "unattach" => {
2796 let_attached_go();
2797 (json!({}), false)
2798 }
2799 "setup" => (setup(), false),
2800 "info" => {
2802 crate::steps::java("texts-info", "{}");
2803 (setup(), false)
2804 }
2805 v if v.starts_with("access") => (crate::access::call(v, &asked), false),
2807 "ask" => {
2808 crate::steps::java("texts-ask", "{}");
2809 (setup(), false)
2810 }
2811 "part" => {
2812 let problem = crate::share::set_part(rules::PART, asked["on"].as_bool().unwrap_or(false));
2813 let mut tab = setup();
2814 tab["said"] = json!(problem);
2815 (tab, problem.is_empty())
2816 }
2817 "cap" => {
2818 let problem = set_cap(&text("value")).err().unwrap_or_default();
2819 let mut tab = setup();
2820 tab["said"] = json!(problem);
2821 (tab, false)
2822 }
2823 _ => (json!({}), false),
2824 };
2825 (answer.to_string(), shared)
2826}
2827
2828pub(crate) fn sends_texts(phone_id: &str) -> bool {
2831 if demo() && !phone() {
2832 return phone_id == "demo-phone";
2833 }
2834 !phone_id.is_empty() && sealed().is_some() && rules::own_file(&root(), rules::LOG, phone_id).is_file()
2835}
2836
2837pub(crate) fn sent_to(phone_id: &str, dial: &str, since_ms: i64) -> Vec<sioul_core::replies::Mine> {
2841 let Some((here, seal, _)) = sealed() else { return Vec::new() };
2842 let (_, requests, outcomes) = records(&here, &seal);
2843 let region = sioul_core::reach::region(&load_config());
2844 let to = phones::key(dial, region);
2845 let shared = if demo() && !phone() { sending_phone() } else { phones().into_iter().find(|p| p.id == phone_id) }.filter(|p| p.exported > 0).map(|p| p.exported * 1000);
2847 let now = Zoned::now();
2848 let mut sent: Vec<&Request> = requests.iter().filter(|r| r.device == here && r.phone == phone_id && r.written >= since_ms && phones::key(&r.to, region) == to).collect();
2849 sent.sort_by_key(|r| r.written);
2850 sent.into_iter()
2851 .map(|r| {
2852 let said = rules::said(rules::state_of(&r.key, &outcomes), shared, tr(), &now, r.multimedia());
2853 sioul_core::replies::Mine { key: r.key.clone(), time: rules::time_of_day(r.written, &now), text: r.body.clone(), said: said.text, waiting: said.state == "waiting", again: said.again, texts: true }
2854 })
2855 .collect()
2856}
2857
2858pub(crate) fn texted_since(since_ms: i64) -> Vec<String> {
2861 let Some((here, seal, _)) = sealed() else { return Vec::new() };
2862 let (_, requests, outcomes) = records(&here, &seal);
2863 let region = sioul_core::reach::region(&load_config());
2864 let took = |key: &str| outcomes.iter().any(|o| o.key == key && matches!(o.state.as_str(), "handed" | "sent" | "delivered") && o.at >= since_ms);
2865 requests.iter().filter(|r| r.device == here && took(&r.key)).map(|r| phones::key(&r.to, region)).collect()
2866}
2867
2868pub(crate) fn readable() -> bool {
2870 sealed().is_some() && sending_phone().is_some()
2871}
2872
2873pub(crate) fn conversation_of(numbers: &[String]) -> String {
2876 if sealed().is_none() {
2877 return String::new();
2878 }
2879 let region = sioul_core::reach::region(&load_config());
2880 numbers.iter().map(|n| phones::key(n.trim().trim_start_matches(sioul_core::porch::TEL), region)).find(|k| phones::is_whole(k)).unwrap_or_default()
2881}
2882
2883#[cfg(test)]
2884mod tests {
2885 use super::*;
2886
2887 const NOW: i64 = 1_791_446_400_000;
2889
2890 fn part(key: &str, kind: &str, part: i64, parts: i64, code: i64, uri: &str, status: i64, at: i64) -> PartResult {
2891 PartResult { key: key.into(), kind: kind.into(), part, parts, code, uri: uri.into(), status, at, ..PartResult::default() }
2892 }
2893
2894 #[test]
2898 fn requests_are_read_from_their_small_files_too_each_once() {
2899 let base = std::env::temp_dir().join(format!("sioul-texts-copies-{}", std::process::id()));
2900 let _ = std::fs::remove_dir_all(&base);
2901 let seal = Seal::Key(TextSeal::new(&[3u8; 32]));
2902 let desk = "0f8fad5b-d9cb-469f-a165-70867728950e";
2903 let request = |key: &str| Request { key: key.into(), phone: "phone".into(), to: "0199001234".into(), body: "On my way".into(), sub: -1, written: NOW, ..Request::default() };
2904 let (records, folder, fetched) = (base.join("records"), base.join("folder"), base.join("fetched"));
2905 rules::append(&rules::own_file(&records, rules::SEND, desk), &[request("a")], &seal, None).unwrap();
2906 rules::append(&rules::own_file(&folder, rules::SEND, desk), &[request("a"), request("b")], &seal, None).unwrap();
2907 rules::append(&rules::own_file(&fetched, rules::SEND, desk), &[request("a"), request("b"), request("c")], &seal, None).unwrap();
2908 let read = requests_from(&[records.clone(), folder, fetched], &seal);
2909 assert_eq!(read.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), ["a", "b", "c"]);
2910 assert!(read.iter().all(|r| r.device == desk), "{read:?}");
2911 assert_eq!(requests_from(&[records, base.join("none")], &seal).len(), 1);
2913 let _ = std::fs::remove_dir_all(&base);
2914 }
2915
2916 #[test]
2917 fn a_text_s_parts_make_one_outcome_each_and_delivered_only_when_reported() {
2918 let results = [
2919 part("a", "sent", 0, 2, -1, "content://sms/414", -1, NOW),
2920 part("a", "sent", 1, 2, -1, "content://sms/414", -1, NOW + 1000),
2921 part("a", "delivered", 0, 2, -1, "", 0, NOW + 5000),
2922 part("b", "sent", 0, 1, -1, "content://sms/415", -1, NOW),
2923 part("b", "delivered", 0, 1, -1, "", 0, NOW + 3000),
2924 part("c", "sent", 0, 1, 4, "", -1, NOW),
2925 part("d", "sent", 0, 2, -1, "content://sms/416", -1, NOW),
2926 ];
2927 let said: Vec<(String, String, String)> = outcomes_from(&results).into_iter().map(|o| (o.key, o.state, if o.row.is_empty() { o.reason } else { o.row })).collect();
2928 assert_eq!(said, [
2929 ("a".to_string(), "sent".to_string(), "sms-414".to_string()),
2930 ("b".into(), "sent".into(), "sms-415".into()),
2931 ("b".into(), "delivered".into(), String::new()),
2932 ("c".into(), "failed".into(), "no-service".into()),
2933 ], "a: one part of two delivered, no word; d: one part of two sent, not yet");
2934 }
2935
2936 #[test]
2941 fn what_java_reads_is_kept_whole_and_read_here() {
2942 let json = r#"{"texts":[
2943 {"kind":"sms","fields":{"_id":412,"thread_id":7,"address":"01 99 00 12 34","date":1791446340000,"date_sent":1791446338000,"type":1,"read":1,"seen":1,"body":"Votre rendez-vous est déplacé.","sub_id":2,"service_center":"+33609001390","protocol":0,"status":-1}},
2944 {"kind":"mms","fields":{"_id":31,"thread_id":9,"date":1791446340,"msg_box":1,"m_type":132,"sub_id":1},
2945 "addrs":[{"address":"+33465710042","type":137,"charset":106},{"address":"insert-address-token","type":151,"charset":106}],
2946 "parts":[{"_id":70,"seq":-1,"ct":"application/smil","text":"<smil/>"},{"_id":71,"seq":0,"ct":"image/jpeg","name":"a.jpg","size":220000,"cid":"<a>"},{"_id":72,"seq":1,"ct":"video/mp4","cl":"b.mp4","size":30000000},{"_id":73,"seq":2,"ct":"text/plain","text":"Le colis est arrivé.","chset":106}]},
2947 {"kind":"mms","fields":{"_id":32,"thread_id":11,"date":1791446350,"msg_box":1,"m_type":130},"addrs":[{"address":"+33465710042","type":137}],"parts":[]},
2948 {"kind":"sms","fields":{"_id":413,"thread_id":12,"address":"+33465710042","date":1791446360000,"type":1,"body":"Pour le groupe."}}],
2949 "threads":{"7":["0199001234"],"9":["+33465710042"],"12":["+33465710042","+33199001234"]},"sms":413,"mms":32,"allowed":true}"#;
2950 let answer: Value = serde_json::from_str(json).unwrap();
2951 let region = phones::region_named("FR");
2952 let threads: BTreeMap<String, Vec<String>> = answer["threads"].as_object().unwrap().iter().map(|(k, v)| (k.clone(), v.as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect())).collect();
2953 let texts: Vec<Text> = raws(&answer).iter().map(|r| text_of(r, &threads, region, 10 << 20)).collect();
2954 let sms = &texts[0];
2955 assert_eq!((sms.id.as_str(), sms.with.as_slice(), sms.sub, sms.direction.as_str(), sms.at), ("sms-412@1791446340000", &["+33199001234".to_string()][..], 2, "in", 1_791_446_340_000), "named by its id and its own date");
2956 assert_eq!(sms.fields.len(), 13, "every column kept");
2957 let mms = &texts[1];
2958 assert_eq!((mms.id.as_str(), mms.at, mms.with.as_slice(), mms.body.as_str(), mms.picture, mms.sub), ("mms-31@1791446340", 1_791_446_340_000, &["+33465710042".to_string()][..], "Le colis est arrivé.", true, 1));
2959 assert_eq!(mms.parts.iter().map(|p| p.state.as_str()).collect::<Vec<_>>(), ["text", "here", "too-big", "text"]);
2960 assert_eq!((mms.parts[1].name.as_str(), mms.parts[2].name.as_str(), mms.parts[1].size), ("a.jpg", "b.mp4", 220_000));
2961 assert_eq!((mms.parts[1].fields.get("cid"), mms.parts[3].fields.get("chset"), mms.parts[3].fields.get("text")), (Some(&json!("<a>")), Some(&json!(106)), None), "the other columns kept, none twice");
2962 assert_eq!(mms.addrs.len(), 2);
2963 assert_eq!((texts[2].parts.len(), texts[2].with.as_slice()), (0, &["+33465710042".to_string()][..]), "nothing to download yet");
2964 assert_eq!(texts[3].with, ["+33465710042", "+33199001234"], "a group, from its conversation");
2965 assert_eq!(texts[3].from, "+33465710042");
2966 let waiting = Raw { kind: "mms".into(), parts: vec![[("ct".to_string(), json!("image/png")), ("size".to_string(), json!(-1))].into_iter().collect()], ..Raw::default() };
2967 assert_eq!(text_of(&waiting, &threads, region, 10 << 20).parts[0].state, "not-downloaded");
2968 assert_eq!(row_of("content://sms/414"), "sms-414");
2969 assert_eq!(row_of(""), "");
2970 }
2971
2972 #[test]
2976 fn a_heavy_import_waits_for_the_charger() {
2977 let first = Reader::default();
2978 assert!(!import_now(&first, true, false, NOW), "the whole history, on the battery: it waits");
2979 assert!(import_now(&first, false, true, NOW), "on the charger");
2980 let caught_up = Reader { started: NOW - 1_000, looked: NOW - 3_600_000, indexed: true, ..Reader::default() };
2981 assert!(import_now(&caught_up, true, false, NOW), "a new text, on the battery");
2982 assert!(!import_now(&caught_up, false, false, NOW) && !import_now(&caught_up, false, true, NOW), "nothing changed: Android not asked");
2983 assert!(import_now(&caught_up, false, false, NOW + 86_400_000), "the day's look: read all the same");
2984 let behind = Reader { started: NOW - 1_000, behind: true, ..caught_up.clone() };
2985 assert!(!import_now(&behind, true, false, NOW), "a full batch left more: on the charger only");
2986 assert!(import_now(&behind, false, true, NOW), "on the charger, batch after batch");
2987 let older: Reader = serde_json::from_str(r#"{"version":2,"sms":10,"mms":3,"started":1}"#).unwrap();
2989 assert!(!older.behind && import_now(&older, true, false, NOW));
2990 assert!(import_now(&older, false, false, NOW) && recent_now(&older, false));
2991 }
2992
2993 #[test]
2994 fn nothing_runs_on_a_computer_without_the_part() {
2995 if phone() || demo() {
2996 return;
2997 }
2998 assert!(!phone_step());
2999 let (answer, shared) = call("send", r#"{"to":"01 99 00 12 34","body":"Bonjour"}"#);
3000 assert!(!shared, "{answer}");
3001 }
3002
3003 #[derive(Default)]
3010 struct Provider {
3011 sms: std::cell::RefCell<BTreeMap<i64, Value>>,
3012 mms: std::cell::RefCell<BTreeMap<i64, Value>>,
3013 media: std::cell::RefCell<BTreeMap<i64, Vec<u8>>>,
3014 hidden: std::cell::RefCell<BTreeSet<i64>>,
3015 hidden_mms: std::cell::RefCell<BTreeSet<i64>>,
3016 unlisted: std::cell::Cell<Option<&'static str>>,
3017 asked: std::cell::RefCell<Vec<(String, usize)>>,
3018 }
3019
3020 impl Android for Provider {
3021 fn ask(&self, verb: &str, asked: &Value) -> Value {
3022 let (hidden, hidden_mms) = (self.hidden.borrow(), self.hidden_mms.borrow());
3023 let chosen = |list: &Value, mark: &Value, id: i64| match list.as_array() {
3025 Some(ids) => ids.iter().any(|v| v.as_i64() == Some(id)),
3026 None => id > mark.as_i64().unwrap_or(0),
3027 };
3028 let answer = match verb {
3029 "texts-read" => {
3030 let limit = asked["limit"].as_u64().unwrap_or(500) as usize;
3031 let mut texts = Vec::new();
3032 for (id, fields) in self.sms.borrow().iter().filter(|(id, _)| !hidden.contains(id) && chosen(&asked["sms_ids"], &asked["sms"], **id)) {
3033 if texts.len() < limit {
3034 texts.push(json!({ "kind": "sms", "fields": fields, "id": id }));
3035 }
3036 }
3037 for (_, row) in self.mms.borrow().iter().filter(|(id, _)| !hidden_mms.contains(id) && chosen(&asked["mms_ids"], &asked["mms"], **id)) {
3038 if texts.len() < limit {
3039 let mut row = row.clone();
3040 row["kind"] = json!("mms");
3041 texts.push(row);
3042 }
3043 }
3044 json!({ "texts": texts, "threads": {}, "allowed": true })
3045 }
3046 "texts-ids" => {
3047 let sms: Vec<(i64, i64)> = self.sms.borrow().iter().filter(|(id, _)| !hidden.contains(id)).map(|(id, f)| (*id, f["date"].as_i64().unwrap())).collect();
3048 let mms: Vec<(i64, i64)> = self.mms.borrow().iter().filter(|(id, _)| !hidden_mms.contains(id)).map(|(id, r)| (*id, r["fields"]["date"].as_i64().unwrap())).collect();
3049 let mut out = json!({
3050 "sms": sms.iter().map(|r| r.0).collect::<Vec<_>>(),
3051 "sms_at": sms.iter().map(|r| r.1).collect::<Vec<_>>(),
3052 "mms": mms.iter().map(|r| r.0).collect::<Vec<_>>(),
3053 "mms_at": mms.iter().map(|r| r.1).collect::<Vec<_>>(),
3054 "media": self.media.borrow().values().map(Vec::len).sum::<usize>(),
3055 "allowed": true,
3056 });
3057 if let Some(table) = self.unlisted.get()
3058 && let Some(listing) = out.as_object_mut()
3059 {
3060 listing.remove(table);
3061 listing.remove(&format!("{table}_at"));
3062 }
3063 out
3064 }
3065 "texts-part" => {
3066 let target = PathBuf::from(asked["target"].as_str().unwrap_or_default());
3067 match self.media.borrow().get(&asked["id"].as_i64().unwrap_or(0)) {
3068 Some(bytes) => {
3069 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
3070 std::fs::write(&target, bytes).unwrap();
3071 json!({ "copied": bytes.len(), "allowed": true })
3072 }
3073 None => json!({ "copied": -1, "allowed": true }),
3074 }
3075 }
3076 _ => Value::Null,
3077 };
3078 let rows = answer["texts"].as_array().map_or(0, Vec::len);
3079 self.asked.borrow_mut().push((verb.to_string(), rows));
3080 answer
3081 }
3082 }
3083
3084 impl Provider {
3085 fn text(&self, id: i64, at: i64, body: &str) {
3086 self.sms.borrow_mut().insert(id, json!({ "_id": id, "thread_id": 3, "address": "04 65 71 00 42", "date": at, "date_sent": at, "type": 1, "read": 1, "body": body }));
3087 }
3088 fn picture(&self, id: i64, at: i64) {
3089 let part = 1000 + 10 * id;
3090 self.mms.borrow_mut().insert(id, json!({
3091 "fields": { "_id": id, "thread_id": 4, "date": at / 1000, "msg_box": 1, "m_type": 132, "sub_id": 1 },
3092 "addrs": [{ "address": "+33465710042", "type": 137, "charset": 106 }],
3093 "parts": [{ "_id": part, "mid": id, "seq": 0, "ct": "image/png", "name": "colis.png", "size": 4 }, { "_id": part + 1, "mid": id, "seq": 1, "ct": "text/plain", "text": "Le colis." }],
3094 }));
3095 self.media.borrow_mut().insert(part, vec![0x89, b'P', b'N', u8::try_from(id % 200).unwrap()]);
3096 }
3097 fn questions(&self) -> Vec<(String, usize)> {
3099 std::mem::take(&mut *self.asked.borrow_mut())
3100 }
3101 }
3102
3103 struct Bench {
3105 base: PathBuf,
3106 files: Files,
3107 vault: Vault,
3108 seal: Seal,
3109 }
3110
3111 impl Bench {
3112 fn new(name: &str) -> Bench {
3113 let base = std::env::temp_dir().join(format!("sioul-texts-{name}-{}", std::process::id()));
3114 let _ = std::fs::remove_dir_all(&base);
3115 let files = Files { log: base.join("data/texts/log/phone.jsonl"), reader: base.join("state/texts/reader.json"), incoming: base.join("state/texts/incoming") };
3116 let vault: Vault = (base.join("folder"), [5u8; 32]);
3117 std::fs::create_dir_all(&vault.0).unwrap();
3118 Bench { base, files, vault, seal: Seal::Key(TextSeal::new(&[6u8; 32])) }
3119 }
3120 fn step(&self, phone: &Provider, now: i64, charging: bool) -> bool {
3121 import_step(phone, &self.files, &self.seal, &self.vault, phones::region_named("FR"), now, charging)
3122 }
3123 fn look(&self, phone: &Provider, now: i64) -> bool {
3124 look_again(phone, &self.files, &self.seal, now)
3125 }
3126 fn log(&self) -> Vec<Text> {
3127 rules::read_file::<Text>(&self.files.log, &self.seal)
3128 }
3129 fn reader(&self) -> Reader {
3130 Reader::load_at(&self.files.reader)
3131 }
3132 fn rows(&self) -> Vec<rules::Row> {
3134 self.log().iter().filter(|t| t.deleted == 0).filter_map(Text::row).collect()
3135 }
3136 fn each_once(&self, phone: &Provider) {
3138 let rows = self.rows();
3139 let once: BTreeSet<rules::Row> = rows.iter().copied().collect();
3140 assert_eq!(once.len(), rows.len(), "no row twice");
3141 let listed = listed(&phone.ask("texts-ids", &json!({}))).unwrap();
3142 assert!(listed.is_subset(&once), "every row read: {} missing", listed.difference(&once).count());
3143 }
3144 }
3145
3146 impl Drop for Bench {
3147 fn drop(&mut self) {
3148 let _ = std::fs::remove_dir_all(&self.base);
3149 }
3150 }
3151
3152 #[test]
3164 fn a_conversation_deleted_in_sioul_leaves_the_phones_log_for_good() {
3165 let bench = Bench::new("hidden");
3166 let phone = Provider::default();
3167 for id in 1..=3 {
3168 phone.text(id, NOW - 3_600_000 + id * 60_000, "Votre code : 123456");
3169 }
3170 assert!(bench.step(&phone, NOW, false));
3171 assert_eq!(bench.rows().len(), 3);
3172 let root = bench.base.join("data/texts");
3173 let mark = rules::TextMark { at: NOW, conversation: "+33465710042".into(), read: NOW, hidden: NOW };
3174 rules::write_mark(&root, "computer", &mark, &bench.seal).unwrap();
3175 assert_eq!(rules::drop_hidden(&bench.files.log, &rules::Marks::read_all(&root, &bench.seal), &bench.seal).unwrap(), 3);
3176 assert!(bench.rows().is_empty(), "gone from the log, so from every computer's copy");
3177 assert!(!bench.step(&phone, NOW + 60_000, false));
3179 assert!(bench.rows().is_empty());
3180 phone.text(5, NOW - 1_000, "Votre code : 654321");
3182 bench.step(&phone, NOW + 90_000, false);
3183 assert!(bench.rows().is_empty(), "dated before it was deleted: hidden too");
3184 phone.text(4, NOW + 120_000, "Votre colis arrive.");
3186 assert!(bench.step(&phone, NOW + 180_000, false));
3187 assert_eq!(bench.rows().iter().map(|r| r.id).collect::<Vec<_>>(), [4]);
3188 }
3189
3190 #[test]
3191 fn a_text_that_comes_during_a_long_import_goes_first() {
3192 let bench = Bench::new("import");
3193 let day = 86_400_000;
3194 let phone = Provider::default();
3195 for id in 1..=1_200 {
3196 phone.text(id, NOW - 700 * day + id * 60_000, "Un ancien texte.");
3197 }
3198 for id in 1..=3 {
3199 phone.picture(id, NOW - 600 * day + id * 60_000);
3200 }
3201 for id in 1_201..=1_205 {
3202 phone.text(id, NOW - 3 * 3_600_000 + id, "Ce matin.");
3203 }
3204 let ids = |rows: &[rules::Row]| rows.iter().map(|r| r.id).collect::<Vec<_>>();
3205 assert!(recent_now(&bench.reader(), false) && !import_now(&bench.reader(), true, false, NOW));
3207 assert!(bench.step(&phone, NOW, false));
3208 assert_eq!(ids(&bench.rows()), [1_205, 1_204, 1_203, 1_202, 1_201]);
3209 let reader = bench.reader();
3210 assert!(reader.behind && !import_now(&reader, true, false, NOW + 60_000) && recent_now(&reader, true));
3211 assert!(!recent_now(&reader, false), "nothing new: Android not asked");
3212 phone.text(1_206, NOW + 60_000, "Et maintenant ?");
3214 assert!(bench.step(&phone, NOW + 70_000, false));
3215 assert_eq!(ids(&bench.rows()).last(), Some(&1_206));
3216 let mut steps = 0;
3218 while bench.reader().behind {
3219 assert!(bench.step(&phone, NOW + 100_000, true));
3220 steps += 1;
3221 assert!(steps <= 3, "1,203 rows: three steps");
3222 }
3223 let rows = bench.rows();
3224 assert_eq!(rows.len(), 1_209);
3225 assert_eq!((rows[6].mms, rows[6].id, rows[9].mms, rows[9].id), (true, 3, false, 1_200), "the history's newest first");
3226 assert!(bench.log().iter().filter(|t| t.id.starts_with("mms-")).all(|t| t.parts[0].state == "here" && !t.parts[0].hash.is_empty()), "the pictures put in the sharing");
3227 assert!(!bench.reader().behind, "caught up");
3228 for id in 1_207..=2_106 {
3230 phone.text(id, NOW - 500 * day + id * 60_000, "Restauré.");
3231 }
3232 phone.hidden.borrow_mut().insert(1_500);
3233 phone.text(2_107, NOW + 3_600_000, "Après la restauration.");
3234 assert!(import_now(&bench.reader(), true, false, NOW + 3_700_000), "caught up: a new text, on the battery");
3235 assert!(bench.step(&phone, NOW + 3_700_000, false), "the text that came, not the block");
3236 assert_eq!(ids(&bench.rows()).last(), Some(&2_107));
3237 assert_eq!(bench.rows().len(), 1_210);
3238 assert!(bench.reader().behind);
3239 let mut steps = 0;
3240 while bench.reader().behind {
3241 assert!(bench.step(&phone, NOW + 3_800_000, true));
3242 steps += 1;
3243 assert!(steps <= 2, "899 rows: two steps");
3244 }
3245 assert_eq!(bench.rows()[1_210].id, 2_106, "the block's newest first");
3246 phone.hidden.borrow_mut().clear();
3248 assert!(bench.step(&phone, NOW + 3_900_000, false));
3249 assert_eq!(ids(&bench.rows()).last(), Some(&1_500));
3250 bench.each_once(&phone);
3251 assert!(!bench.look(&phone, NOW + 2 * day), "nothing gone");
3252 let reader = bench.reader();
3253 assert_eq!((reader.done_texts, reader.logged.len(), reader.behind, reader.left), (2_110, 2_110, false, false));
3254 }
3255
3256 #[test]
3262 fn a_text_that_takes_a_deleted_text_s_id_is_read() {
3263 let bench = Bench::new("reused");
3264 let phone = Provider::default();
3265 for id in 1..=10 {
3266 phone.text(id, NOW - 3_600_000 + id * 1_000, "Avant.");
3267 }
3268 assert!(bench.step(&phone, NOW - 3_000_000, true));
3269 assert!(!bench.reader().behind);
3270 phone.sms.borrow_mut().remove(&10);
3272 phone.text(10, NOW, "Un nouveau texte, sous le même numéro.");
3273 assert!(bench.step(&phone, NOW + 60_000, false), "read at the next step, on the battery");
3274 let log = bench.log();
3275 assert_eq!(log.len(), 11);
3276 assert!(log.iter().any(|t| t.id == rules::Row { mms: false, id: 10, date: NOW }.name() && t.body.starts_with("Un nouveau")));
3277 assert!(bench.look(&phone, NOW + 2 * 86_400_000));
3279 assert!(!bench.look(&phone, NOW + 4 * 86_400_000));
3280 let shown = rules::fold_logs(bench.log().into_iter().map(|t| Text { device: "phone".into(), ..t }).collect());
3281 assert_eq!(shown.iter().filter(|t| t.deleted > 0).map(|t| t.body.as_str()).collect::<Vec<_>>(), ["Avant."]);
3282 assert_eq!(shown.len(), 11);
3283 let mut sent = vec![Outcome { key: "k".into(), state: "sent".into(), at: NOW, row: "sms-10".into(), ..Outcome::default() }, Outcome { key: "g".into(), state: "sent".into(), at: NOW, row: "sms-77".into(), ..Outcome::default() }];
3285 name_rows(&phone, &mut sent);
3286 assert_eq!((sent[0].row.clone(), sent[1].row.clone()), (rules::Row { mms: false, id: 10, date: NOW }.name(), String::new()), "a row Android no longer holds: unnamed");
3287 }
3288
3289 #[test]
3293 fn a_provider_emptied_and_filled_again_is_read() {
3294 let bench = Bench::new("emptied");
3295 let phone = Provider::default();
3296 for id in 1..=100 {
3297 phone.text(id, NOW - 86_400_000 + id * 60_000, "Avant.");
3298 }
3299 assert!(bench.step(&phone, NOW - 3_600_000, true));
3300 phone.sms.borrow_mut().clear();
3301 for id in 1..=3 {
3302 phone.text(id, NOW + id * 1_000, "Après.");
3303 }
3304 assert!(bench.step(&phone, NOW + 60_000, false));
3305 assert_eq!(bench.log().iter().filter(|t| t.body == "Après.").count(), 3);
3306 assert!(bench.look(&phone, NOW + 2 * 86_400_000));
3307 assert_eq!(bench.log().iter().filter(|t| t.deleted > 0).count(), 100, "each of the old ones said deleted, once");
3308 bench.each_once(&phone);
3309 }
3310
3311 #[test]
3315 fn the_newest_by_date_come_first_wherever_their_ids() {
3316 let day = 86_400_000;
3317 let bench = Bench::new("own-under");
3319 let phone = Provider::default();
3320 for id in 1..=50 {
3321 phone.text(id, NOW - 5 * 3_600_000 + id * 60_000, "Ce matin, sur ce téléphone.");
3322 }
3323 for id in 51..=2_050 {
3324 phone.text(id, NOW - 400 * day + id * 60_000, "Restauré.");
3325 }
3326 assert!(bench.step(&phone, NOW, false));
3327 let rows = bench.rows();
3328 assert_eq!((rows.len(), rows.iter().all(|r| r.id <= 50)), (50, true), "this phone's own, not the block");
3329 let bench = Bench::new("during");
3331 let phone = Provider::default();
3332 for id in 1..=10 {
3333 phone.text(id, NOW - 3_600_000 + id, "Avant.");
3334 }
3335 assert!(bench.step(&phone, NOW - 3_000_000, true));
3336 for id in 11..=1_510 {
3337 phone.text(id, NOW - 300 * day + id * 60_000, "Restauré.");
3338 }
3339 phone.text(1_511, NOW, "Pendant la restauration.");
3340 for id in 1_512..=3_011 {
3341 phone.text(id, NOW - 300 * day + id * 60_000, "Restauré.");
3342 }
3343 assert!(bench.step(&phone, NOW + 60_000, false));
3344 assert_eq!(bench.rows().iter().map(|r| r.id).filter(|id| *id > 10).collect::<Vec<_>>(), [1_511], "the text that came, alone");
3345 }
3346
3347 #[test]
3351 fn pictures_that_come_are_read_whatever_their_dates() {
3352 let bench = Bench::new("pictures");
3353 let day = 86_400_000;
3354 let phone = Provider::default();
3355 for id in 1..=600 {
3356 phone.text(id, NOW - 400 * day + id * 60_000, "Restauré.");
3357 }
3358 assert!(!bench.step(&phone, NOW - 2 * day, false), "a history: nothing of the last day");
3359 assert!(bench.reader().behind);
3360 phone.text(601, NOW + 60_700, "Une photo arrive.");
3362 assert!(bench.step(&phone, NOW + 60_800, false));
3363 phone.picture(1, NOW + 60_900);
3364 assert!(bench.step(&phone, NOW + 61_000, false), "the picture, on the battery");
3365 assert!(bench.rows().iter().any(|r| r.mms && r.id == 1));
3366 phone.picture(2, NOW + 120_000);
3368 phone.hidden_mms.borrow_mut().insert(2);
3369 phone.picture(3, NOW + 180_000);
3370 assert!(bench.step(&phone, NOW + 190_000, false));
3371 phone.hidden_mms.borrow_mut().clear();
3372 assert!(bench.step(&phone, NOW + 240_000, false), "the picture sent, on the battery");
3373 assert!(bench.rows().iter().any(|r| r.mms && r.id == 2));
3374 assert!(bench.reader().behind, "the history waits still");
3375 }
3376
3377 #[test]
3382 fn a_step_with_a_history_waiting_costs_what_a_caught_up_step_costs() {
3383 let bench = Bench::new("cost");
3384 let phone = Provider::default();
3385 for id in 1..=200 {
3386 phone.text(id, NOW - 3_600_000 + id, "Avant.");
3387 }
3388 assert!(bench.step(&phone, NOW - 3_000_000, true));
3389 phone.questions();
3390 assert!(!bench.step(&phone, NOW, false));
3391 assert_eq!(phone.questions(), [("texts-ids".to_string(), 0)], "caught up, nothing new");
3392 for id in 201..=3_500 {
3393 phone.text(id, NOW - 400 * 86_400_000 + id * 60_000, "Restauré.");
3394 }
3395 assert!(!bench.step(&phone, NOW + 1_000, false), "the block found: nothing of it on the battery");
3396 phone.questions();
3397 assert!(!bench.step(&phone, NOW + 2_000, false));
3398 assert_eq!(phone.questions(), [("texts-ids".to_string(), 0)], "a history waits, nothing new");
3399 phone.text(3_501, NOW + 3_000, "Un texte arrive.");
3400 assert!(bench.step(&phone, NOW + 4_000, false));
3401 assert_eq!(phone.questions(), [("texts-ids".to_string(), 0), ("texts-read".to_string(), 1)], "a text came: it alone");
3402 assert!(bench.step(&phone, NOW + 5_000, true));
3403 assert_eq!(phone.questions(), [("texts-ids".to_string(), 0), ("texts-read".to_string(), 500)], "on the charger");
3404 }
3405
3406 #[test]
3411 fn a_reader_of_an_older_version_reads_what_its_log_lacks() {
3412 let bench = Bench::new("older");
3413 let phone = Provider::default();
3414 let old: Vec<Text> = (1..=10).map(|id| Text { id: format!("sms-{id}"), at: NOW - 7_200_000 + id * 1_000, direction: "in".into(), with: vec!["+33465710042".into()], body: "Avant.".into(), ..Text::default() }).collect();
3416 rules::append(&bench.files.log, &old, &bench.seal, Some(&|t: &Text| t.line_id())).unwrap();
3417 for id in 1..=9 {
3418 phone.text(id, NOW - 7_200_000 + id * 1_000, "Avant.");
3419 }
3420 phone.text(10, NOW - 600_000, "Perdu par la version 2.");
3422 std::fs::create_dir_all(bench.files.reader.parent().unwrap()).unwrap();
3423 for older in [r#"{"version":2,"sms":10,"mms":0,"started":1,"done_texts":7}"#, r#"{"version":3,"sms":10,"sms_read":[[0,10]],"started":1,"done_texts":7}"#] {
3424 std::fs::write(&bench.files.reader, older).unwrap();
3425 let reader = bench.reader();
3426 assert_eq!((reader.version, reader.indexed), (READER_VERSION, false), "{older}");
3427 }
3428 assert!(bench.step(&phone, NOW, false), "the text version 2 lost, read");
3429 assert!(bench.log().iter().any(|t| t.body.starts_with("Perdu")));
3430 assert_eq!(bench.reader().done_texts, 11, "the log's own count");
3431 assert!(bench.look(&phone, NOW + 2 * 86_400_000), "the older text of that id: deleted");
3432 bench.each_once(&phone);
3433 phone.text(11, NOW + 3 * 86_400_000, "Après un arrêt.");
3435 let line = Text { id: rules::Row { mms: false, id: 11, date: NOW + 3 * 86_400_000 }.name(), at: NOW + 3 * 86_400_000, direction: "in".into(), with: vec!["+33465710042".into()], body: "Après un arrêt.".into(), ..Text::default() };
3436 rules::append(&bench.files.log, &[line], &bench.seal, Some(&|t: &Text| t.line_id())).unwrap();
3437 assert!(!bench.step(&phone, NOW + 3 * 86_400_000 + 1_000, false), "in the log already: no line twice");
3438 assert_eq!(bench.reader().done_texts, 12);
3439 bench.each_once(&phone);
3440 }
3441
3442 #[test]
3447 fn a_table_not_listed_is_never_taken_for_an_empty_one() {
3448 let bench = Bench::new("unlisted");
3449 let phone = Provider::default();
3450 for id in 1..=5 {
3451 phone.text(id, NOW - 3_600_000 + id, "Avant.");
3452 }
3453 phone.picture(1, NOW - 3_500_000);
3454 assert!(bench.step(&phone, NOW - 3_000_000, true));
3455 for table in ["sms", "mms"] {
3456 phone.unlisted.set(Some(table));
3457 phone.text(5 + bench.log().len() as i64, NOW, "Pendant.");
3458 assert!(!bench.step(&phone, NOW + 1_000, false), "{table} not listed: nothing read");
3459 assert!(!bench.look(&phone, NOW + 2 * 86_400_000), "{table} not listed: nothing said deleted");
3460 assert!(bench.log().iter().all(|t| t.deleted == 0));
3461 }
3462 phone.unlisted.set(None);
3463 assert!(bench.step(&phone, NOW + 3 * 86_400_000, false));
3464 assert!(!bench.look(&phone, NOW + 4 * 86_400_000), "nothing gone");
3465 bench.each_once(&phone);
3466 }
3467
3468 #[test]
3473 fn a_row_that_comes_with_a_block_is_read_on_the_battery() {
3474 let bench = Bench::new("with-block");
3475 let phone = Provider::default();
3476 phone.text(1, NOW + 60_700, "Une photo arrive.");
3477 assert!(bench.step(&phone, NOW + 60_800, true));
3478 assert!(!bench.reader().behind);
3479 for id in 2..=601 {
3481 phone.text(id, NOW - 400 * DAY_MS + id * 60_000, "Restauré.");
3482 }
3483 phone.picture(1, NOW + 60_900);
3484 assert!(bench.step(&phone, NOW + 61_000, false));
3485 assert!(bench.rows().iter().any(|r| r.mms && r.id == 1), "the picture, on the battery");
3486 assert!(bench.reader().behind, "the history waits for the charger");
3487 }
3488
3489 #[test]
3497 fn a_row_its_provider_dated_again_is_one_text() {
3498 let bench = Bench::new("redated");
3499 let phone = Provider::default();
3500 phone.text(1, NOW - 600_000, "Bonjour.");
3501 phone.text(2, NOW - 500_000, "À demain.");
3502 phone.picture(3, NOW - 400_000);
3503 phone.text(4, NOW - 3 * 3_600_000, "Oui.");
3504 assert!(bench.step(&phone, NOW - 300_000, true));
3505 let picture = phone.mms.borrow_mut().remove(&3).unwrap();
3507 let mut moved = picture.clone();
3508 moved["fields"]["date"] = json!((NOW - 399_000) / 1000);
3509 phone.mms.borrow_mut().insert(3, moved);
3510 phone.text(1, NOW - 599_999, "Bonjour.");
3511 phone.text(2, NOW - 100_000, "Autre chose.");
3513 phone.text(4, NOW - 50_000, "Oui.");
3515 assert!(bench.step(&phone, NOW, false));
3516 let log = bench.log();
3517 let mut marks: Vec<(String, bool)> = log.iter().filter(|t| t.deleted > 0).map(|t| (t.id.split('@').next().unwrap_or_default().to_string(), !t.moved.is_empty())).collect();
3518 marks.sort();
3519 assert_eq!(marks, [("mms-3".to_string(), true), ("sms-1".to_string(), true)], "the re-dated rows' old lines marked moved, at once; text 2 not (other words), nor text 4 (the same words, hours later)");
3520 assert!(bench.look(&phone, NOW + DAY_MS));
3521 let shown = rules::fold_logs(bench.log().into_iter().map(|t| Text { device: "phone".into(), ..t }).collect());
3522 assert_eq!(shown.iter().filter(|t| t.body == "Bonjour.").count(), 1, "the text once, under its new date");
3523 assert_eq!(shown.iter().filter(|t| t.id.starts_with("mms-3@")).count(), 1, "the picture once");
3524 assert!(shown.iter().all(|t| t.deleted == 0 || t.body == "À demain." || t.body == "Oui."), "only the texts truly deleted said so");
3525 assert!(shown.iter().any(|t| t.body == "À demain." && t.deleted > 0));
3526 assert!(shown.iter().any(|t| t.body == "Autre chose." && t.deleted == 0));
3527 let mut oui: Vec<(i64, bool)> = shown.iter().filter(|t| t.body == "Oui.").map(|t| (t.at, t.deleted > 0)).collect();
3528 oui.sort_unstable();
3529 assert_eq!(oui, [(NOW - 3 * 3_600_000, true), (NOW - 50_000, false)], "her first \"Oui.\" deleted on the phone, kept here; her next one there");
3530 }
3531
3532 #[test]
3536 fn a_look_that_cannot_list_waits_an_hour() {
3537 let bench = Bench::new("look-wait");
3538 let phone = Provider::default();
3539 phone.text(1, NOW - 3_600_000, "Avant.");
3540 assert!(bench.step(&phone, NOW - 3_000_000, true));
3541 phone.unlisted.set(Some("mms"));
3542 phone.questions();
3543 let day = NOW + DAY_MS;
3544 assert!(!bench.look(&phone, day));
3545 assert_eq!(phone.questions().iter().filter(|(verb, _)| verb == "texts-ids").count(), 1);
3546 assert_eq!(bench.reader().looked, day - DAY_MS + LOOK_AGAIN_MS, "tried again in an hour");
3547 assert!(!import_now(&bench.reader(), false, false, day + 60_000), "the day's look no longer due at every step");
3548 assert!(!bench.look(&phone, day + 30 * 60_000));
3549 assert!(phone.questions().is_empty(), "nothing asked within the hour");
3550 phone.unlisted.set(None);
3551 assert!(!bench.look(&phone, day + LOOK_AGAIN_MS + 1), "nothing gone");
3552 assert_eq!(bench.reader().looked, day + LOOK_AGAIN_MS + 1, "listed whole: done for the day");
3553 }
3554
3555 #[test]
3558 fn rows_a_log_could_not_take_are_read_again() {
3559 let bench = Bench::new("unwritable");
3560 let phone = Provider::default();
3561 phone.text(1, NOW, "Bonjour.");
3562 std::fs::create_dir_all(&bench.files.log).unwrap();
3563 assert!(!bench.step(&phone, NOW + 1_000, true));
3564 assert_eq!(bench.reader().logged.len(), 0);
3565 std::fs::remove_dir_all(&bench.files.log).unwrap();
3566 assert!(bench.step(&phone, NOW + 2_000, false));
3567 bench.each_once(&phone);
3568 }
3569
3570 #[test]
3574 fn a_block_found_late_reads_only_its_last_day_on_the_battery() {
3575 let bench = Bench::new("late");
3576 let day = 86_400_000;
3577 let phone = Provider::default();
3578 assert!(!bench.step(&phone, NOW - 30 * day, false));
3580 assert!(!bench.reader().behind);
3581 for id in 1..=600 {
3583 phone.text(id, NOW - 20 * day + id * 2_880_000, "Restauré.");
3584 }
3585 assert!(bench.step(&phone, NOW, false));
3586 let read = bench.log();
3587 assert!(!read.is_empty() && read.iter().all(|t| t.at >= NOW - day), "the last day's alone: {}", read.len());
3588 assert_eq!(read.len(), 31, "a text every 48 minutes: 31 from a day before to now");
3589 }
3590
3591 const MINUTE: i64 = 60_000;
3594
3595 #[derive(Default)]
3599 struct Carrier {
3600 max: u64,
3601 mms: bool,
3602 handed: std::cell::RefCell<Vec<(String, i64, Vec<u8>)>>,
3604 filed: std::cell::RefCell<BTreeMap<String, (i64, i64, i64)>>,
3606 refuse: Option<&'static str>,
3608 look_fails: std::cell::Cell<bool>,
3610 statuses: std::cell::RefCell<BTreeMap<String, i64>>,
3612 }
3613
3614 impl Android for Carrier {
3615 fn ask(&self, verb: &str, asked: &Value) -> Value {
3616 match verb {
3617 "texts-limits" => json!({ "limits": [{ "sub": 1, "max": self.max, "width": 2592, "height": 1944, "recipients": 20, "group": true, "mms": self.mms }] }),
3618 "texts-send-mms" => {
3619 if let Some(why) = self.refuse {
3620 return json!({ "handed": false, "reason": why });
3621 }
3622 let bytes = std::fs::read(asked["path"].as_str().unwrap_or_default()).unwrap_or_default();
3623 self.handed.borrow_mut().push((asked["key"].as_str().unwrap_or_default().to_string(), asked["sub"].as_i64().unwrap_or(-9), bytes));
3624 json!({ "handed": true })
3625 }
3626 "texts-find-mms" if self.look_fails.get() => json!({ "allowed": true, "found": [], "error": true }),
3627 "texts-find-mms" => {
3628 let key = asked["key"].as_str().unwrap_or_default();
3629 let status = self.statuses.borrow().get(key).copied().unwrap_or(0);
3630 let found: Vec<Value> = self.filed.borrow().get(key).map(|(id, date, mbox)| json!({ "id": id, "date": date, "box": mbox, "status": status })).into_iter().collect();
3631 json!({ "allowed": true, "found": found })
3632 }
3633 "texts-read" => {
3634 let rows: Vec<Value> = self.filed.borrow().values().map(|(id, date, _)| json!({ "kind": "mms", "fields": { "_id": id, "date": date } })).collect();
3635 json!({ "allowed": true, "texts": rows })
3636 }
3637 "texts-find" => json!({ "allowed": true, "texts": [] }),
3638 _ => Value::Null,
3639 }
3640 }
3641 }
3642
3643 fn every_device(_: &str) -> rules::Standing {
3645 rules::Standing::Counted
3646 }
3647
3648 struct Desk {
3651 base: PathBuf,
3652 vault: Vault,
3653 seal: Seal,
3654 }
3655
3656 impl Desk {
3657 fn new(name: &str) -> Desk {
3658 let base = std::env::temp_dir().join(format!("sioul-mms-{name}-{}", std::process::id()));
3659 let _ = std::fs::remove_dir_all(&base);
3660 std::fs::create_dir_all(base.join("folder")).unwrap();
3661 std::fs::create_dir_all(base.join("share")).unwrap();
3662 rules::Ledger::open(&base.join("private"), NOW - DAY_MS).unwrap();
3664 Desk { vault: (base.join("folder"), [5u8; 32]), seal: Seal::Key(TextSeal::new(&[6u8; 32])), base }
3665 }
3666
3667 fn step<'a>(&'a self, android: &'a dyn Android) -> Step<'a> {
3668 self.step_counting(android, &every_device)
3669 }
3670
3671 fn step_counting<'a>(&'a self, android: &'a dyn Android, standing: &'a dyn Fn(&str) -> rules::Standing) -> Step<'a> {
3673 Step { here: "phone", seal: &self.seal, region: phones::region_named("FR"), root: self.base.join("data"), private: self.base.join("private"), copies: Vec::new(), memory: self.base.join("share").join("memory.json"), vault: Some(&self.vault), android, standing }
3674 }
3675
3676 fn asks(&self, request: &Request) {
3678 rules::write_request(&self.base.join("data"), "desk", request, &self.seal).unwrap();
3679 }
3680
3681 fn seals(&self, bytes: &[u8]) -> rules::Medium {
3683 let source = self.base.join("medium");
3684 std::fs::write(&source, bytes).unwrap();
3685 let (hash, size) = sioul_sync::blobs::hash_file(&source).unwrap();
3686 sioul_sync::blobs::put(&self.vault.0, &self.vault.1, &source, &hash).unwrap();
3687 rules::Medium { hash, ct: "image/jpeg".into(), size }
3688 }
3689
3690 fn described(&self, bytes: &[u8]) -> rules::Medium {
3692 let source = self.base.join("medium");
3693 std::fs::write(&source, bytes).unwrap();
3694 let (hash, size) = sioul_sync::blobs::hash_file(&source).unwrap();
3695 rules::Medium { hash, ct: "image/jpeg".into(), size }
3696 }
3697
3698 fn outcomes(&self) -> Vec<Outcome> {
3699 rules::read_file::<Outcome>(&rules::own_file(&self.base.join("data"), rules::OUTCOME, "phone"), &self.seal)
3700 }
3701
3702 fn states(&self, key: &str) -> Vec<(String, String)> {
3703 self.outcomes().into_iter().filter(|o| o.key == key).map(|o| (o.state, o.reason)).collect()
3704 }
3705
3706 fn ledger(&self) -> rules::Ledger {
3707 rules::Ledger::open(&self.base.join("private"), NOW).unwrap()
3708 }
3709
3710 fn result(&self, key: &str, code: i64, uri: &str, conf: &[u8], at: i64) {
3712 self.result_http(key, code, uri, conf, 0, at);
3713 }
3714
3715 fn result_http(&self, key: &str, code: i64, uri: &str, conf: &[u8], http: i64, at: i64) {
3717 use std::io::Write as _;
3718 let hex: String = conf.iter().map(|b| format!("{b:02x}")).collect();
3719 let line = json!({ "key": key, "kind": "mms", "code": code, "uri": uri, "http": http, "conf": hex, "at": at });
3720 let path = self.base.join("private").join(rules::RESULTS);
3721 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
3722 writeln!(file, "{line}").unwrap();
3723 }
3724
3725 fn wanted(&self) -> Vec<String> {
3726 sioul_sync::blobs::Wanted::load(&self.base.join("share").join("memory.json")).media.into_keys().collect()
3727 }
3728 }
3729
3730 impl Drop for Desk {
3731 fn drop(&mut self) {
3732 let _ = std::fs::remove_dir_all(&self.base);
3733 }
3734 }
3735
3736 fn conf(key: &str, status: u8) -> Vec<u8> {
3738 let mut out = vec![0x8C, 0x81, 0x98];
3739 out.extend_from_slice(key.as_bytes());
3740 out.extend_from_slice(&[0x00, 0x8D, 0x92, 0x92, status, 0x8B]);
3741 out.extend_from_slice(b"msg-1\0");
3742 out
3743 }
3744
3745 fn mms(key: &str, media: Vec<rules::Medium>, written: i64) -> Request {
3746 Request { key: key.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), words: "Voilà l’entrée.".into(), media, sub: 1, written, ..Request::default() }
3747 }
3748
3749 const KEY_A: &str = "0f3c9a5e7b2d4f6a8c1e3b5d7f9a2c4e";
3750 const KEY_B: &str = "1a2b3c4d5e6f708192a3b4c5d6e7f809";
3751
3752 #[test]
3758 fn a_multimedia_message_goes_once_its_media_are_here() {
3759 let desk = Desk::new("goes");
3760 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
3761 let picture: Vec<u8> = (0..3_000u32).map(|i| (i % 251) as u8).collect();
3762 let medium = desk.described(&picture);
3763 desk.asks(&mms(KEY_A, vec![medium.clone()], NOW - MINUTE));
3764 let step = desk.step(&carrier);
3765 assert!(!step.decide(NOW));
3767 assert!(desk.ledger().state(KEY_A).is_none() && desk.outcomes().is_empty());
3768 assert_eq!(desk.wanted(), [sioul_sync::blobs::name(&desk.vault.1, &medium.hash)]);
3769 desk.seals(&picture);
3771 assert!(step.decide(NOW + MINUTE));
3772 assert_eq!(desk.states(KEY_A), [("handed".to_string(), String::new())]);
3773 assert!(desk.ledger().claims.iter().any(|c| c.key == KEY_A && c.state == "claimed" && c.mms), "a multimedia message's claim");
3774 assert!(desk.wanted().is_empty(), "asked for no more");
3775 {
3776 let handed = carrier.handed.borrow();
3777 assert_eq!(handed.len(), 1);
3778 let (key, sub, pdu) = &handed[0];
3779 assert_eq!((key.as_str(), *sub), (KEY_A, 1));
3780 assert_eq!(&pdu[..3], [0x8C, 0x80, 0x98], "an M-Send.req");
3781 assert!(pdu.windows(KEY_A.len()).any(|w| w == KEY_A.as_bytes()), "its key as its transaction id");
3782 assert!(pdu.windows(b"+33199001234/TYPE=PLMN".len()).any(|w| w == b"+33199001234/TYPE=PLMN"));
3783 assert!(pdu.windows(picture.len()).any(|w| w == picture.as_slice()), "the picture whole");
3784 assert!(!pdu.windows(b".media".len()).any(|w| w == b".media") && pdu.windows(b"image1.jpg".len()).any(|w| w == b"image1.jpg"), "plain names");
3785 }
3786 assert!(!step.decide(NOW + 2 * MINUTE));
3788 assert_eq!(carrier.handed.borrow().len(), 1, "never twice");
3789 carrier.filed.borrow_mut().insert(KEY_A.into(), (31, (NOW + 2 * MINUTE) / 1000, 2));
3791 desk.result(KEY_A, -1, "content://mms/sent/31", &conf(KEY_A, 0x80), NOW + 2 * MINUTE);
3792 assert!(step.copy_results(NOW + 3 * MINUTE));
3793 let sent = desk.outcomes().into_iter().find(|o| o.key == KEY_A && o.state == "sent").unwrap();
3794 assert_eq!(sent.row, format!("mms-31@{}", (NOW + 2 * MINUTE) / 1000));
3795 assert_eq!(desk.ledger().state(KEY_A), Some("done"));
3796 assert!(!step.pdu_path(KEY_A).exists(), "its file gone");
3797 assert!(!step.look_for_lost(NOW + 50 * MINUTE));
3799 }
3800
3801 #[test]
3805 fn a_multimedia_message_its_carrier_cannot_take_is_refused_in_words() {
3806 let desk = Desk::new("refused");
3807 let picture: Vec<u8> = (0..20_000u32).map(|i| (i % 253) as u8).collect();
3808 let medium = desk.seals(&picture);
3809 desk.asks(&mms(KEY_A, vec![medium.clone()], NOW - MINUTE));
3810 let small = Carrier { max: 10_000, mms: true, ..Carrier::default() };
3811 assert!(desk.step(&small).decide(NOW));
3812 assert_eq!(desk.states(KEY_A), [("refused".to_string(), "too-large".to_string())]);
3813 assert!(small.handed.borrow().is_empty() && !desk.step(&small).pdu_path(KEY_A).exists());
3814 desk.asks(&mms(KEY_B, vec![medium], NOW - MINUTE));
3815 let none = Carrier { max: 614_400, mms: false, ..Carrier::default() };
3816 assert!(desk.step(&none).decide(NOW));
3817 assert_eq!(desk.states(KEY_B), [("refused".to_string(), "no-mms".to_string())]);
3818 let key_c = "2b3c4d5e6f708192a3b4c5d6e7f80910";
3820 desk.asks(&mms(key_c, vec![desk.seals(b"another picture")], NOW - MINUTE));
3821 let refusing = Carrier { max: 614_400, mms: true, refuse: Some("permission"), ..Carrier::default() };
3822 assert!(desk.step(&refusing).decide(NOW));
3823 assert_eq!(desk.states(key_c), [("failed".to_string(), "permission".to_string())]);
3824 assert!(!desk.step(&refusing).pdu_path(key_c).exists());
3825 }
3826
3827 #[test]
3830 fn a_multimedia_message_whose_media_never_came_expires_said_so() {
3831 let desk = Desk::new("late");
3832 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
3833 desk.asks(&mms(KEY_A, vec![desk.described(b"a picture that never came")], NOW - 10 * MINUTE));
3834 assert!(!desk.step(&carrier).decide(NOW));
3835 assert_eq!(desk.wanted().len(), 1);
3836 assert!(desk.step(&carrier).decide(NOW + 6 * MINUTE));
3837 assert_eq!(desk.states(KEY_A), [("expired".to_string(), "media".to_string())]);
3838 assert!(desk.wanted().is_empty(), "asked for no more");
3839 assert!(carrier.handed.borrow().is_empty());
3840 }
3841
3842 #[test]
3846 fn a_multimedia_message_s_result_is_read_with_its_carrier_s_answer() {
3847 let desk = Desk::new("results");
3848 let carrier = Carrier::default();
3849 carrier.filed.borrow_mut().insert(KEY_A.into(), (40, NOW / 1000, 2));
3850 let (k3, k4, k5) = ("3c4d5e6f708192a3b4c5d6e7f8091011", "4d5e6f708192a3b4c5d6e7f809101112", "5e6f708192a3b4c5d6e7f80910111213");
3851 desk.result(KEY_A, -1, "content://mms/sent/40", &[], NOW);
3852 desk.result(KEY_B, -1, "", &[], NOW);
3853 desk.result(k3, -1, "content://mms/sent/41", &conf(k3, 0xE5), NOW);
3854 desk.result(k4, 11, "", &[], NOW);
3855 desk.result(k5, 4, "", &conf(k5, 0xC2), NOW);
3856 let (k6, k7) = ("6f708192a3b4c5d6e7f8091011121314", "708192a3b4c5d6e7f809101112131415");
3858 desk.result_http(k6, 4, "", &[], 0, NOW);
3859 desk.result_http(k7, 4, "", &[], 503, NOW);
3860 carrier.look_fails.set(true);
3862 assert!(desk.step(&carrier).copy_results(NOW + MINUTE));
3863 assert!(desk.states(KEY_B).is_empty(), "a look not made says nothing: {:?}", desk.states(KEY_B));
3864 carrier.look_fails.set(false);
3865 assert!(desk.step(&carrier).copy_results(NOW + 2 * MINUTE));
3866 let said: BTreeMap<String, (String, String)> = desk.outcomes().into_iter().map(|o| (o.key.clone(), (o.state, if o.row.is_empty() { o.reason } else { o.row }))).collect();
3867 assert_eq!(said[KEY_A], ("sent".to_string(), format!("mms-40@{}", NOW / 1000)), "Android's Ok, the sent box holds it");
3868 assert_eq!(said[KEY_B], ("doubt".to_string(), "answer".to_string()), "Android's Ok, in no box Sioul sees: the carrier may have it (I-g)");
3869 assert_eq!(said[k3], ("failed".to_string(), "carrier-content".to_string()));
3870 assert_eq!(said[k4], ("failed".to_string(), "mms-data-off".to_string()));
3871 assert_eq!(said[k5], ("failed".to_string(), "carrier-busy".to_string()));
3872 assert_eq!(said[k6], ("doubt".to_string(), "network".to_string()), "the carrier may have it");
3873 assert_eq!(said[k7], ("failed".to_string(), "carrier-refused".to_string()));
3874 let count = desk.outcomes().len();
3876 desk.step(&carrier).copy_results(NOW + 2 * MINUTE);
3877 assert_eq!(desk.outcomes().len(), count);
3878 }
3879
3880 #[test]
3885 fn android_s_ok_with_an_answer_nobody_read_says_the_doubt() {
3886 let desk = Desk::new("ok-unread");
3887 let carrier = Carrier::default();
3888 let (k1, k2) = ("8192a3b4c5d6e7f80910111213141516", "92a3b4c5d6e7f8091011121314151617");
3889 carrier.filed.borrow_mut().insert(k1.into(), (60, NOW / 1000, 5));
3890 carrier.statuses.borrow_mut().insert(k1.into(), 0xE5);
3891 carrier.filed.borrow_mut().insert(k2.into(), (61, NOW / 1000, 5));
3892 desk.result(k1, -1, "", &[], NOW);
3893 desk.result(k2, -1, "", &[], NOW);
3894 assert!(desk.step(&carrier).copy_results(NOW + MINUTE));
3895 assert_eq!(desk.states(k1), [("failed".to_string(), "carrier-content".to_string())], "the refusal Android read");
3896 assert_eq!(desk.states(k2), [("doubt".to_string(), "answer".to_string())], "an answer nobody could read");
3897 }
3898
3899 #[test]
3904 fn java_s_results_are_kept_a_month_and_never_said_again() {
3905 let desk = Desk::new("results-trim");
3906 let carrier = Carrier::default();
3907 let (old, new) = ("a3b4c5d6e7f8091011121314151617a8", "b4c5d6e7f8091011121314151617a8b9");
3908 desk.result_http(old, 11, "", &[], 0, NOW - 40 * DAY_MS);
3909 desk.result_http(new, 11, "", &[], 0, NOW - DAY_MS);
3910 assert!(desk.step(&carrier).copy_results(NOW));
3911 assert!(desk.states(old).is_empty(), "past the month: its outcome trimmed long ago, never said again");
3912 assert_eq!(desk.states(new), [("failed".to_string(), "mms-data-off".to_string())]);
3913 let private = desk.base.join("private");
3914 trim_results(&private, NOW).unwrap();
3915 let lines = |name: &str| std::fs::read_to_string(private.join(name)).unwrap_or_default().lines().count();
3916 assert_eq!((lines(rules::RESULTS), lines(RESULTS_ASIDE)), (1, 2), "the month's line kept; the old file set aside");
3917 let late = "c5d6e7f8091011121314151617a8b9c0";
3919 let mut aside = std::fs::read_to_string(private.join(RESULTS_ASIDE)).unwrap();
3920 aside.push_str(&format!("{}\n", json!({ "key": late, "kind": "mms", "code": 11, "http": 0, "at": NOW })));
3921 std::fs::write(private.join(RESULTS_ASIDE), aside).unwrap();
3922 assert!(desk.step(&carrier).copy_results(NOW + MINUTE));
3923 assert_eq!(desk.states(late), [("failed".to_string(), "mms-data-off".to_string())]);
3924 trim_results(&private, NOW + DAY_MS).unwrap();
3925 let kept: Vec<String> = results_of(&private, NOW + DAY_MS).into_iter().map(|r| r.key).collect();
3926 assert!(kept.contains(&new.to_string()) && kept.contains(&late.to_string()) && !kept.contains(&old.to_string()), "{kept:?}");
3927 assert_eq!(lines(rules::RESULTS), 2, "the late line brought back once, the month's kept");
3928 }
3929
3930 #[test]
3937 fn a_multimedia_message_with_no_word_is_looked_for_until_android_s_last_word() {
3938 let desk = Desk::new("lost");
3939 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
3940 desk.asks(&mms(KEY_A, vec![desk.seals(b"picture a")], NOW - MINUTE));
3941 desk.asks(&mms(KEY_B, vec![desk.seals(b"picture b")], NOW - MINUTE));
3942 let step = desk.step(&carrier);
3943 assert!(step.decide(NOW));
3944 assert_eq!(carrier.handed.borrow().len(), 2);
3945 assert!(!step.look_for_lost(NOW + 39 * MINUTE), "Android may still wait for a network");
3946 carrier.filed.borrow_mut().insert(KEY_A.into(), (51, (NOW + 30 * MINUTE) / 1000, 2));
3947 assert!(step.look_for_lost(NOW + 41 * MINUTE));
3948 assert_eq!(desk.states(KEY_A).last().unwrap().0, "sent");
3949 assert_eq!(desk.states(KEY_B).last().unwrap().0, "handed", "it may still go");
3950 assert!(!step.pdu_path(KEY_A).exists() && step.pdu_path(KEY_B).exists());
3951 assert!(!step.look_for_lost(NOW + 70 * MINUTE));
3952 carrier.look_fails.set(true);
3953 assert!(!step.look_for_lost(NOW + 99 * MINUTE), "a look not made, before Android's last word: nothing said");
3954 carrier.look_fails.set(false);
3955 assert!(step.look_for_lost(NOW + 100 * MINUTE));
3956 assert_eq!(desk.states(KEY_B).last().unwrap().0, "doubt");
3957 assert!(!step.pdu_path(KEY_B).exists());
3958 assert!(!step.decide(NOW + 101 * MINUTE) && carrier.handed.borrow().len() == 2, "never sent again");
3959 assert!(!step.look_for_lost(NOW + 120 * MINUTE), "said once");
3960 }
3961
3962 #[test]
3965 fn only_files_made_fit_are_sent() {
3966 let root = std::env::temp_dir().join(format!("sioul-made-in-{}", std::process::id()));
3967 let folder = root.join("texts-attached");
3968 std::fs::create_dir_all(&folder).unwrap();
3969 std::fs::write(folder.join("1-0.jpg"), b"made").unwrap();
3970 std::fs::write(root.join("IMG_0001.JPG"), b"chosen").unwrap();
3971 assert!(made_in(&folder, &folder.join("1-0.jpg")));
3972 assert!(!made_in(&folder, &root.join("IMG_0001.JPG")), "the file chosen itself");
3973 assert!(!made_in(&folder, &folder.join("..").join("IMG_0001.JPG")), "out by ..");
3974 assert!(!made_in(&folder, &folder), "the folder");
3975 assert!(!made_in(&folder, &folder.join("gone.jpg")), "none there");
3976 #[cfg(unix)]
3977 {
3978 std::os::unix::fs::symlink(root.join("IMG_0001.JPG"), folder.join("2-0.jpg")).unwrap();
3979 assert!(!made_in(&folder, &folder.join("2-0.jpg")), "a link out");
3980 }
3981 assert!(!made_in(&root.join("none"), &folder.join("1-0.jpg")), "no folder made");
3982 let _ = std::fs::remove_dir_all(&root);
3983 }
3984
3985 #[test]
3988 fn what_the_phone_said_is_shown_read_only() {
3989 let sim = |sub: i32, mms: bool, width: u32| rules::Limits { sub, max: 614_400, width, height: if width > 0 { 1944 } else { 0 }, recipients: 20, group: true, mms };
3990 assert_eq!(limit_lines(&[]).len(), 1, "none said yet");
3991 let one = limit_lines(&[sim(1, true, 2592)]);
3992 assert!(one.len() == 1 && one[0].contains("2592") && one[0].contains("1944"), "{one:?}");
3993 assert!(!limit_lines(&[sim(1, true, 0)])[0].contains('×'), "no picture size: none said");
3994 let two = limit_lines(&[sim(1, true, 2592), sim(2, false, 2592)]);
3995 assert!(two.len() == 2 && two[0].contains("2592") && !two[1].contains("2592"), "{two:?}");
3996 }
3997
3998 #[test]
4002 fn what_a_message_from_here_may_weigh() {
4003 let sim = |sub: i32, mms: bool, max: u64| rules::Limits { sub, max, width: 2592, height: 1944, recipients: 20, group: true, mms };
4004 assert_eq!(weighed(None, 1, None), Weigh::Unknown, "before the phone said: no Attach");
4005 let two = [sim(1, true, 614_400), sim(2, false, 307_200)];
4006 assert_eq!(weighed(Some(&two), 1, None), Weigh::Up { max: 614_400, side: 2592 }, "the conversation's SIM");
4007 assert_eq!(weighed(Some(&two), 2, None), Weigh::None, "its SIM takes no multimedia message");
4008 assert_eq!(weighed(Some(&two), -1, None), Weigh::Up { max: 614_400, side: 2592 }, "no SIM known: those that take them");
4009 assert_eq!(weighed(Some(&two), 7, None), Weigh::Up { max: 614_400, side: 2592 }, "a SIM the phone did not name");
4010 assert_eq!(weighed(Some(&two), 1, Some(300)), Weigh::Up { max: 307_200, side: 2592 }, "300 kB chosen here");
4011 assert_eq!(weighed(Some(&[sim(1, true, 102_400)]), 1, Some(300)), Weigh::Up { max: 102_400, side: 2592 }, "never more than the carrier's");
4012 assert_eq!(weighed(Some(&[sim(1, true, 614_400), sim(3, true, 307_200)]), -1, None), Weigh::Up { max: 307_200, side: 2592 }, "the smallest of two");
4013 assert_eq!(weighed(Some(&[sim(1, true, 0)]), 1, None), Weigh::Unknown, "a line with no size");
4014 assert_eq!(weighed(Some(&[]), 1, None), Weigh::Unknown);
4015 }
4016
4017 #[test]
4022 fn a_quick_step_reads_the_log_only_for_a_text_about_to_go() {
4023 let desk = Desk::new("log-reads");
4024 a_log_of(&desk, 200);
4025 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4026 let reads = || LOG_READS.with(std::cell::Cell::get);
4027 let text = |key: &str, sub: i32| Request { key: key.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub, written: NOW - MINUTE, ..Request::default() };
4028 desk.asks(&text(KEY_A, -1));
4030 rules::record(&desk.base.join("private"), &Claim { key: KEY_A.into(), at: NOW - MINUTE, state: "done".into(), ..Claim::default() }).unwrap();
4031 let step = desk.step(&carrier);
4032 let before = reads();
4033 step.copy_results(NOW);
4034 step.decide(NOW);
4035 step.look_for_lost(NOW);
4036 assert_eq!(reads(), before, "every request decided: the log never read");
4037 desk.asks(&text(KEY_B, 1));
4039 step.decide(NOW);
4040 assert_eq!(reads(), before, "its SIM named");
4041 desk.asks(&Request { sub: -1, ..mms("4d5e6f708192a3b4c5d6e7f809101112", vec![desk.seals(b"here"), desk.described(b"on its way")], NOW - MINUTE) });
4043 let opened = std::fs::read_dir(desk.base.join("private")).map(|d| d.count()).unwrap_or(0);
4044 step.decide(NOW);
4045 assert_eq!(reads(), before, "a message waiting for its media");
4046 assert_eq!(std::fs::read_dir(desk.base.join("private")).map(|d| d.count()).unwrap_or(0), opened, "nothing opened or written for it");
4047 desk.asks(&text("1b2c3d4e5f60718293a4b5c6d7e8f901", -1));
4049 desk.asks(&text("2c3d4e5f60718293a4b5c6d7e8f90112", -1));
4050 step.decide(NOW);
4051 assert_eq!(reads(), before + 1, "read once for both");
4052 }
4053
4054 #[test]
4058 fn only_a_device_the_sharing_counts_asks_the_phone_to_send() {
4059 let desk = Desk::new("counted");
4060 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4061 desk.asks(&Request { key: KEY_A.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: 1, written: NOW - MINUTE, ..Request::default() });
4062 let unknown = |_: &str| rules::Standing::Unknown;
4064 assert!(!desk.step_counting(&carrier, &unknown).decide(NOW));
4065 assert!(desk.states(KEY_A).is_empty() && desk.ledger().state(KEY_A).is_none(), "waiting, nothing said");
4066 desk.step(&carrier).decide(NOW + MINUTE);
4068 assert_eq!(desk.ledger().state(KEY_A), Some("claimed"));
4069 desk.asks(&Request { key: KEY_B.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: 1, written: NOW, ..Request::default() });
4071 let gone = |_: &str| rules::Standing::Gone;
4072 assert!(desk.step_counting(&carrier, &gone).decide(NOW + MINUTE));
4073 assert_eq!(desk.states(KEY_B), [("refused".to_string(), "not-counted".to_string())]);
4074 desk.asks(&Request { key: "3c4d5e6f708192a3b4c5d6e7f8091011".into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: 1, written: NOW, ..Request::default() });
4076 assert!(desk.step_counting(&carrier, &unknown).decide(NOW + 16 * MINUTE));
4077 assert_eq!(desk.states("3c4d5e6f708192a3b4c5d6e7f8091011"), [("expired".to_string(), String::new())]);
4078 let line = std::fs::read_to_string(rules::own_file(&desk.base.join("data"), rules::SEND, "desk")).unwrap();
4080 std::fs::write(rules::own_file(&desk.base.join("data"), rules::SEND, "laptop"), line).unwrap();
4081 assert!(rules::read_requests(&desk.base.join("data"), &desk.seal).iter().all(|r| r.device == "desk"), "the moved line passed over");
4082 }
4083
4084 fn a_log_of(desk: &Desk, count: usize) {
4087 let texts: Vec<Text> = (0..count)
4088 .map(|n| {
4089 let at = NOW - DAY_MS * 400 + n as i64 * 600_000;
4090 let number = format!("+3319900{:04}", n % 140);
4091 let body: String = (0..90).map(|i| char::from(b'a' + ((n * 7 + i) % 26) as u8)).collect();
4092 let mut fields = BTreeMap::new();
4093 for (k, v) in [("_id", json!(n)), ("thread_id", json!(n % 140)), ("address", json!(number)), ("person", Value::Null), ("date", json!(at)), ("date_sent", json!(at - 2_000)), ("protocol", json!(0)), ("read", json!(1)), ("status", json!(-1)), ("type", json!(1 + n % 2)), ("reply_path_present", json!(0)), ("subject", Value::Null), ("body", json!(body)), ("service_center", json!("+33199000000")), ("locked", json!(0)), ("sub_id", json!(1)), ("error_code", json!(0)), ("creator", json!("foundation.e.message")), ("seen", json!(1))] {
4094 fields.insert(k.to_string(), v);
4095 }
4096 Text { id: format!("sms-{n}@{at}"), at, thread: (n % 140).to_string(), direction: if n % 2 == 0 { "in".into() } else { "out".into() }, with: vec![number], body, sub: 1, fields, ..Text::default() }
4097 })
4098 .collect();
4099 rules::append(&rules::own_file(&desk.base.join("data"), rules::LOG, "phone"), &texts, &desk.seal, None).unwrap();
4100 }
4101
4102 #[test]
4107 #[ignore]
4108 fn a_quick_step_s_time_with_twenty_thousand_texts() {
4109 let desk = Desk::new("quick-time");
4110 a_log_of(&desk, 20_000);
4111 let request = Request { key: KEY_A.into(), phone: "phone".into(), to: "01 99 00 12 34".into(), body: "J’arrive.".into(), sub: -1, written: NOW - MINUTE, ..Request::default() };
4112 desk.asks(&request);
4113 rules::record(&desk.base.join("private"), &Claim { key: KEY_A.into(), at: NOW - MINUTE, state: "done".into(), ..Claim::default() }).unwrap();
4114 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4115 let step = desk.step(&carrier);
4116 let mut best = std::time::Duration::MAX;
4117 for _ in 0..5 {
4118 let t = std::time::Instant::now();
4119 step.copy_results(NOW);
4120 step.decide(NOW);
4121 step.look_for_lost(NOW);
4122 step.sweep_outgoing(NOW);
4123 best = best.min(t.elapsed());
4124 }
4125 println!("the quick step, 20,000 texts in the log, its one request decided: {best:?} (best of five)");
4126 let here = desk.seals(&[7u8; 120_000]);
4128 let absent = desk.described(b"not here yet");
4129 desk.asks(&Request { sub: -1, ..mms(KEY_B, vec![here, absent], NOW - MINUTE) });
4131 let mut waiting = std::time::Duration::MAX;
4132 for _ in 0..5 {
4133 let t = std::time::Instant::now();
4134 step.copy_results(NOW);
4135 step.decide(NOW);
4136 step.look_for_lost(NOW);
4137 step.sweep_outgoing(NOW);
4138 waiting = waiting.min(t.elapsed());
4139 }
4140 assert!(desk.ledger().state(KEY_B).is_none(), "still waiting");
4141 println!("the same with a multimedia message waiting for one of its two media: {waiting:?} (best of five)");
4142 }
4143
4144 #[test]
4147 fn the_phone_says_what_its_carriers_let_it_send() {
4148 let desk = Desk::new("limits");
4149 let carrier = Carrier { max: 614_400, mms: true, ..Carrier::default() };
4150 let step = desk.step(&carrier);
4151 assert!(step.publish_limits(NOW));
4152 assert!(!step.publish_limits(NOW + 3_600_000), "the same, within the day");
4153 let lines = || desk.outcomes().into_iter().filter(|o| o.state == "limits").collect::<Vec<_>>();
4154 assert_eq!(lines().len(), 1);
4155 assert_eq!((lines()[0].key.as_str(), lines()[0].limits[0].max, lines()[0].limits[0].sub), ("", 614_400, 1));
4156 let changed = Carrier { max: 307_200, mms: true, ..Carrier::default() };
4157 assert!(desk.step(&changed).publish_limits(NOW + 2 * 3_600_000), "changed: said at once");
4158 assert!(desk.step(&changed).publish_limits(NOW + DAY_MS + 3 * 3_600_000), "a day on: said again");
4159 assert_eq!(lines().len(), 3);
4160 assert_eq!(rules::state_of("", &desk.outcomes()), None, "no request's state");
4161 }
4162}