1use crate::backend::{load_config, tr};
29use jiff::Zoned;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use sioul_core::config::{Config, config_dir, state_dir};
33use sioul_core::phones;
34use sioul_core::porch::Senders;
35use sioul_core::texts::{self as rules, Claim, Outcome, Part, Request, Text, Verdict};
36use sioul_sync::textseal::TextSeal;
37use std::collections::{BTreeMap, BTreeSet};
38use std::path::{Path, PathBuf};
39use std::sync::Mutex;
40use std::sync::atomic::{AtomicBool, Ordering};
41
42fn phone() -> bool {
44 cfg!(target_os = "android") || std::env::var_os("SIOUL_TEXTS").is_some()
45}
46
47fn demo() -> bool {
51 std::env::var_os("SIOUL_DEMO").is_some()
52}
53
54enum Seal {
56 Key(TextSeal),
57 Demo,
58}
59
60impl rules::Sealer for Seal {
61 fn seal(&self, plain: &str) -> String {
62 match self {
63 Seal::Key(seal) => seal.seal(plain),
64 Seal::Demo => format!("demo:{plain}"),
65 }
66 }
67
68 fn open(&self, sealed: &str) -> Option<String> {
69 match self {
70 Seal::Key(seal) => seal.open(sealed),
71 Seal::Demo => sealed.strip_prefix("demo:").map(str::to_string),
72 }
73 }
74}
75
76impl Seal {
77 fn seal_bytes(&self, plain: &[u8]) -> Vec<u8> {
78 match self {
79 Seal::Key(seal) => seal.seal_bytes(plain),
80 Seal::Demo => plain.to_vec(),
81 }
82 }
83
84 fn open_bytes(&self, sealed: &[u8]) -> Option<Vec<u8>> {
85 match self {
86 Seal::Key(seal) => seal.open_bytes(sealed),
87 Seal::Demo => Some(sealed.to_vec()),
88 }
89 }
90}
91
92type Vault = (PathBuf, [u8; 32]);
94
95fn sealed() -> Option<(String, Seal, Option<Vault>)> {
98 if demo() && !phone() {
99 return Some(("demo-desk".into(), Seal::Demo, None));
100 }
101 let (here, vault) = crate::share::vault()?;
102 let (folder, key) = vault?;
103 crate::share::shares(rules::PART).then(|| (here, Seal::Key(TextSeal::new(&key)), Some((folder, key))))
104}
105
106fn root() -> PathBuf {
107 rules::folder()
108}
109
110fn private() -> PathBuf {
111 rules::private_folder()
112}
113
114fn now_ms() -> i64 {
115 jiff::Timestamp::now().as_millisecond()
116}
117
118fn write_whole(path: &Path, bytes: &[u8]) -> Result<(), String> {
120 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
121 if let Some(dir) = path.parent() {
122 std::fs::create_dir_all(dir).map_err(fail)?;
123 }
124 let fresh = path.with_extension(format!("{}.new", std::process::id()));
125 std::fs::write(&fresh, bytes).map_err(fail)?;
126 std::fs::rename(&fresh, path).map_err(|e| {
127 let _ = std::fs::remove_file(&fresh);
128 fail(e)
129 })
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138struct Choice {
139 #[serde(default, skip_serializing_if = "String::is_empty")]
140 phone: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 media_cap_mb: Option<u64>,
143}
144
145fn choice_path() -> PathBuf {
146 config_dir().join("texts.toml")
147}
148
149fn choice() -> Choice {
150 std::fs::read_to_string(choice_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
151}
152
153const CAPS_MB: [u64; 5] = [1, 5, 10, 25, 50];
155
156fn cap_mb() -> u64 {
158 choice().media_cap_mb.unwrap_or(rules::MEDIA_CAP_MB).clamp(1, sioul_sync::blobs::LARGEST >> 20)
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(default)]
168struct Reader {
169 version: u32,
171 sms: i64,
172 mms: i64,
173 started: i64,
174 total_texts: u64,
176 total_media: u64,
177 measured: i64,
178 done_texts: u64,
180 done_media: u64,
181 looked: i64,
183 behind: bool,
186}
187
188const READER_VERSION: u32 = 2;
191
192impl Reader {
193 fn path() -> PathBuf {
194 private().join(rules::READER)
195 }
196
197 fn load() -> Reader {
198 let fresh = || Reader { version: READER_VERSION, ..Reader::default() };
199 match std::fs::read_to_string(Reader::path()).ok().and_then(|t| serde_json::from_str::<Reader>(&t).ok()) {
200 Some(reader) if reader.version == READER_VERSION => reader,
201 Some(_) => {
202 for old in ["log", "send", "outcome", "ask", "answer"] {
204 let _ = std::fs::remove_dir_all(private().join(old));
205 }
206 eprintln!("sioul: texts: the reader starts again from the first text, for the archive");
207 fresh()
208 }
209 None => fresh(),
210 }
211 }
212
213 fn save(&self) -> Result<(), String> {
214 write_whole(&Reader::path(), serde_json::to_string(self).map_err(|e| e.to_string())?.as_bytes())
215 }
216
217 fn measure(&mut self, answer: &Value, now: i64) {
219 let count = |name: &str| answer[name].as_array().map_or(0, Vec::len) as u64;
220 self.total_texts = count("sms") + count("mms");
221 self.total_media = answer["media"].as_u64().unwrap_or(0);
222 self.measured = now;
223 }
224}
225
226#[derive(Debug, Clone, Default, Deserialize)]
228#[serde(default)]
229struct Raw {
230 kind: String,
231 fields: BTreeMap<String, Value>,
232 addrs: Vec<BTreeMap<String, Value>>,
233 parts: Vec<BTreeMap<String, Value>>,
234}
235
236fn int(fields: &BTreeMap<String, Value>, name: &str) -> i64 {
237 fields.get(name).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))).unwrap_or(0)
238}
239
240fn string(fields: &BTreeMap<String, Value>, name: &str) -> String {
241 fields.get(name).and_then(Value::as_str).unwrap_or_default().to_string()
242}
243
244const MMS_FROM: i64 = 137;
246const MMS_TO: i64 = 151;
247const MMS_NOTIFICATION: i64 = 130;
249const OWN_ADDRESS: &str = "insert-address-token";
251
252fn part_of(columns: &BTreeMap<String, Value>, waiting: bool, cap: u64) -> Part {
255 let ct = string(columns, "ct");
256 let words = ct.starts_with("text/plain") || ct == "application/smil";
257 let size = int(columns, "size");
258 let state = if words {
259 "text"
260 } else if waiting || size < 0 {
261 "not-downloaded"
262 } else if u64::try_from(size).unwrap_or(0) > cap {
263 "too-big"
264 } else {
265 "here"
266 };
267 let name = ["name", "cl", "fn"].iter().map(|n| string(columns, n)).find(|n| !n.is_empty()).unwrap_or_default();
268 let mut fields = columns.clone();
270 for held in ["seq", "ct", "text", "size"] {
271 fields.remove(held);
272 }
273 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 }
274}
275
276fn text_of(raw: &Raw, threads: &BTreeMap<String, Vec<String>>, region: Option<&phones::Region>, cap: u64) -> Text {
280 let key = |a: &str| phones::key(a, region);
281 let f = &raw.fields;
282 let thread = int(f, "thread_id").to_string();
283 let people: Vec<String> = threads.get(&thread).map(|list| list.iter().map(|a| key(a)).filter(|k| !k.is_empty()).collect()).unwrap_or_default();
284 let sub = i32::try_from(int(f, "sub_id")).ok().filter(|_| f.contains_key("sub_id")).unwrap_or(-1);
285 if raw.kind == "mms" {
286 let outgoing = matches!(int(f, "msg_box"), 2 | 4);
287 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() };
288 let from = addresses(MMS_FROM).into_iter().next().unwrap_or_default();
289 let with = if !people.is_empty() {
290 people
291 } else if outgoing {
292 addresses(MMS_TO)
293 } else {
294 [from.clone()].into_iter().filter(|k| !k.is_empty()).collect()
295 };
296 let waiting = int(f, "m_type") == MMS_NOTIFICATION;
297 let parts: Vec<Part> = raw.parts.iter().map(|p| part_of(p, waiting, cap)).collect();
298 let body = parts.iter().filter(|p| p.ct.starts_with("text/plain")).map(|p| p.text.as_str()).collect::<Vec<_>>().join("\n");
299 Text {
300 id: format!("mms-{}", int(f, "_id")),
301 at: int(f, "date") * 1000,
303 thread,
304 direction: if outgoing { "out".into() } else { "in".into() },
305 from: if outgoing || with.len() < 2 { String::new() } else { from },
306 with,
307 body,
308 sub,
309 picture: parts.iter().any(|p| p.ct.starts_with("image/")),
310 parts,
311 fields: f.clone(),
312 addrs: raw.addrs.clone(),
313 ..Text::default()
314 }
315 } else {
316 let address = key(&string(f, "address"));
317 let with = if !people.is_empty() { people } else { [address.clone()].into_iter().filter(|k| !k.is_empty()).collect() };
318 let outgoing = matches!(int(f, "type"), 2 | 4 | 5 | 6);
319 Text {
320 id: format!("sms-{}", int(f, "_id")),
321 at: int(f, "date"),
322 thread,
323 direction: if outgoing { "out".into() } else { "in".into() },
324 from: if outgoing || with.len() < 2 { String::new() } else { address },
325 with,
326 body: string(f, "body"),
327 sub,
328 fields: f.clone(),
329 ..Text::default()
330 }
331 }
332}
333
334fn raws(answer: &Value) -> Vec<Raw> {
335 answer["texts"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default()
336}
337
338fn bring_media(text: &mut Text, vault: &Vault, budget: &mut u64) -> bool {
344 let (folder, key) = vault;
345 for part in text.parts.iter_mut().filter(|p| p.state == "here" && p.hash.is_empty()) {
346 if part.size > *budget && *budget < rules::MEDIA_BATCH {
347 return false;
348 }
349 let incoming = private().join("incoming").join(format!("{}-{}", text.id, part.seq));
350 let answer = crate::steps::java("texts-part", &json!({ "id": int(&part.fields, "_id"), "target": incoming.display().to_string() }).to_string());
351 if answer["allowed"] != true {
352 return false;
353 }
354 if answer["copied"].as_i64().unwrap_or(-1) < 0 {
355 part.state = "not-downloaded".into();
357 continue;
358 }
359 let put = sioul_sync::blobs::hash_file(&incoming).map_err(|e| e.to_string()).and_then(|(hash, size)| sioul_sync::blobs::put(folder, key, &incoming, &hash).map(|_| (hash, size)));
360 let _ = std::fs::remove_file(&incoming);
361 match put {
362 Ok((hash, size)) => {
363 part.hash = hash;
364 part.size = size;
365 *budget = budget.saturating_sub(size);
366 }
367 Err(e) => {
368 eprintln!("sioul: texts: {e}");
369 return false;
370 }
371 }
372 }
373 true
374}
375
376fn threads_of(answer: &Value) -> BTreeMap<String, Vec<String>> {
378 answer["threads"]
379 .as_object()
380 .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())
381 .unwrap_or_default()
382}
383
384struct Taken {
387 texts: Vec<Text>,
388 sms: i64,
389 mms: i64,
390 media: u64,
391 rows: usize,
393}
394
395fn take(answer: &Value, vault: &Vault, region: Option<&phones::Region>) -> Taken {
399 let threads = threads_of(answer);
400 let cap = cap_mb() << 20;
401 let mut budget = rules::MEDIA_BATCH;
402 let raws = raws(answer);
403 let mut taken = Taken { texts: Vec::new(), sms: 0, mms: 0, media: 0, rows: raws.len() };
404 for raw in raws {
405 if taken.texts.len() >= rules::BATCH {
406 break;
407 }
408 let id = int(&raw.fields, "_id");
409 let mut text = text_of(&raw, &threads, region, cap);
410 if raw.kind == "mms" {
411 let before = budget;
412 if !bring_media(&mut text, vault, &mut budget) {
413 break;
414 }
415 taken.media += before - budget;
416 taken.mms = taken.mms.max(id);
417 } else {
418 taken.sms = taken.sms.max(id);
419 }
420 taken.texts.push(text);
421 }
422 taken
423}
424
425fn write_log(here: &str, seal: &Seal, texts: &[Text]) -> usize {
427 let line = |t: &Text| t.line_id();
428 rules::append(&rules::own_file(&root(), rules::LOG, here), texts, seal, Some(&line)).unwrap_or_else(|e| {
429 eprintln!("sioul: texts: {e}");
430 0
431 })
432}
433
434fn import_step(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>, now: i64) -> bool {
440 let mut reader = Reader::load();
441 if reader.started == 0 {
442 reader.started = now;
443 }
444 let answer = crate::steps::java("texts-read", &json!({ "sms": reader.sms, "mms": reader.mms, "limit": rules::BATCH }).to_string());
445 if answer["allowed"] != true {
446 return false;
447 }
448 let taken = take(&answer, vault, region);
449 let written = write_log(here, seal, &taken.texts);
450 reader.sms = reader.sms.max(taken.sms);
451 reader.mms = reader.mms.max(taken.mms);
452 reader.done_texts += written as u64;
453 reader.done_media += taken.media;
454 reader.behind = taken.rows >= rules::BATCH || taken.texts.len() < taken.rows;
455 if let Err(e) = reader.save() {
456 eprintln!("sioul: texts: {e}");
457 }
458 if written > 0 {
459 eprintln!("sioul: texts: {written} read for your computers ({} so far, {} bytes of media)", reader.done_texts, reader.done_media);
461 }
462 written > 0
463}
464
465#[derive(Debug, Default, PartialEq, Eq)]
468struct Gaps {
469 missing_sms: Vec<i64>,
470 missing_mms: Vec<i64>,
471 gone: Vec<String>,
472}
473
474fn gaps(log: &[Text], sms: &BTreeSet<i64>, mms: &BTreeSet<i64>, sms_mark: i64, mms_mark: i64) -> Gaps {
475 let row = |id: &str| id.split_once('-').map(|(table, n)| (table.to_string(), n.parse::<i64>().unwrap_or(0)));
476 let logged: BTreeSet<(String, i64)> = log.iter().filter(|t| t.deleted == 0).filter_map(|t| row(&t.id)).collect();
477 let marked: BTreeSet<&str> = log.iter().filter(|t| t.deleted > 0).map(|t| t.id.as_str()).collect();
478 let missing = |table: &str, there: &BTreeSet<i64>, mark: i64| -> Vec<i64> { there.iter().copied().filter(|n| *n <= mark && !logged.contains(&(table.to_string(), *n))).take(rules::BATCH).collect() };
479 let gone = logged
480 .iter()
481 .filter(|(table, n)| match table.as_str() {
482 "sms" => *n <= sms_mark && !sms.contains(n),
483 "mms" => *n <= mms_mark && !mms.contains(n),
484 _ => false,
485 })
486 .map(|(table, n)| format!("{table}-{n}"))
487 .filter(|id| !marked.contains(id.as_str()))
488 .collect();
489 Gaps { missing_sms: missing("sms", sms, sms_mark), missing_mms: missing("mms", mms, mms_mark), gone }
490}
491
492fn look_again(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>, now: i64) -> bool {
497 let mut reader = Reader::load();
498 if now - reader.looked < 86_400_000 {
499 return false;
500 }
501 let answer = crate::steps::java("texts-ids", &json!({ "measure": true }).to_string());
502 if answer["allowed"] != true {
503 return false;
504 }
505 let ids = |name: &str| -> BTreeSet<i64> { answer[name].as_array().map(|a| a.iter().filter_map(Value::as_i64).collect()).unwrap_or_default() };
506 reader.looked = now;
507 reader.measure(&answer, now);
508 if let Err(e) = reader.save() {
509 eprintln!("sioul: texts: {e}");
510 }
511 let log: Vec<Text> = rules::read_file(&rules::own_file(&root(), rules::LOG, here), seal);
512 let found = gaps(&log, &ids("sms"), &ids("mms"), reader.sms, reader.mms);
513 let by_id: BTreeMap<&str, &Text> = log.iter().filter(|t| t.deleted == 0).map(|t| (t.id.as_str(), t)).collect();
514 let marks: Vec<Text> = found
515 .gone
516 .iter()
517 .filter_map(|id| by_id.get(id.as_str()))
518 .map(|t| Text { id: t.id.clone(), at: t.at, direction: t.direction.clone(), with: t.with.clone(), deleted: now, ..Text::default() })
519 .collect();
520 let mut written = write_log(here, seal, &marks);
521 if written > 0 {
522 eprintln!("sioul: texts: {written} deleted on the phone, marked so");
523 }
524 if !found.missing_sms.is_empty() || !found.missing_mms.is_empty() {
525 let answer = crate::steps::java("texts-read", &json!({ "sms_ids": found.missing_sms, "mms_ids": found.missing_mms, "limit": rules::BATCH }).to_string());
526 if answer["allowed"] == true {
527 let taken = take(&answer, vault, region);
528 let caught = write_log(here, seal, &taken.texts);
529 if caught > 0 {
530 eprintln!("sioul: texts: {caught} passed earlier, read now");
531 reader.done_texts += caught as u64;
532 reader.done_media += taken.media;
533 let _ = reader.save();
534 }
535 written += caught;
536 }
537 }
538 written > 0
539}
540
541#[derive(Debug, Clone, Default, Deserialize)]
545#[serde(default)]
546struct PartResult {
547 key: String,
548 kind: String,
549 part: i64,
550 parts: i64,
551 code: i64,
552 uri: String,
553 status: i64,
554 at: i64,
555}
556
557fn reason_of(code: i64) -> &'static str {
559 match code {
560 2 => "radio-off",
561 4 => "no-service",
562 5 => "limit",
563 18 | 30 | 31 => "no-sim",
564 _ => "other",
565 }
566}
567
568fn row_of(uri: &str) -> String {
570 uri.trim().rsplit('/').next().filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())).map(|id| format!("sms-{id}")).unwrap_or_default()
571}
572
573fn outcomes_from(results: &[PartResult]) -> Vec<Outcome> {
577 let mut by: BTreeMap<&str, Vec<&PartResult>> = BTreeMap::new();
578 for r in results.iter().filter(|r| !r.key.is_empty()) {
579 by.entry(r.key.as_str()).or_default().push(r);
580 }
581 let mut out = Vec::new();
582 for (key, parts) in by {
583 let count = parts.iter().map(|p| p.parts.max(1)).max().unwrap_or(1);
584 let sent: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "sent").collect();
585 let at = |list: &[&&PartResult]| list.iter().map(|p| p.at).max().unwrap_or(0);
586 if let Some(failed) = sent.iter().find(|p| p.code != -1) {
587 out.push(Outcome { key: key.into(), state: "failed".into(), at: failed.at, reason: reason_of(failed.code).into(), ..Outcome::default() });
588 continue;
589 }
590 let done: BTreeSet<i64> = sent.iter().map(|p| p.part).collect();
591 if done.len() as i64 >= count {
592 let row = sent.iter().map(|p| row_of(&p.uri)).find(|r| !r.is_empty()).unwrap_or_default();
593 out.push(Outcome { key: key.into(), state: "sent".into(), at: at(&sent), row, ..Outcome::default() });
594 let delivered: Vec<&&PartResult> = parts.iter().filter(|p| p.kind == "delivered" && (0..0x20).contains(&p.status)).collect();
595 if delivered.iter().map(|p| p.part).collect::<BTreeSet<_>>().len() as i64 >= count {
596 out.push(Outcome { key: key.into(), state: "delivered".into(), at: at(&delivered), ..Outcome::default() });
597 }
598 }
599 }
600 out
601}
602
603fn say(here: &str, seal: &Seal, outcomes: &[Outcome]) -> bool {
605 let id = |o: &Outcome| format!("{}/{}", o.key, o.state);
606 match rules::append(&rules::own_file(&root(), rules::OUTCOME, here), outcomes, seal, Some(&id)) {
607 Ok(n) => n > 0,
608 Err(e) => {
609 eprintln!("sioul: texts: {e}");
610 false
611 }
612 }
613}
614
615fn copy_results(here: &str, seal: &Seal, now: i64) -> bool {
617 let results: Vec<PartResult> = std::fs::read_to_string(private().join(rules::RESULTS)).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
618 if results.is_empty() {
619 return false;
620 }
621 let outcomes = outcomes_from(&results);
622 let changed = say(here, seal, &outcomes);
623 let ledger = rules::Ledger::open(&private(), now).ok();
624 for o in &outcomes {
625 if ledger.as_ref().is_some_and(|l| l.state(&o.key) != Some("done")) {
626 let _ = rules::record(&private(), &Claim { key: o.key.clone(), at: now, state: "done".into(), ..Claim::default() });
627 }
628 }
629 changed
630}
631
632fn requests_from(roots: &[PathBuf], seal: &Seal) -> Vec<Request> {
635 let mut requests: Vec<Request> = Vec::new();
636 for root in roots {
637 for request in rules::read_requests(root, seal) {
638 if !requests.iter().any(|known| known.key == request.key) {
639 requests.push(request);
640 }
641 }
642 }
643 requests
644}
645
646fn decide(here: &str, seal: &Seal, region: Option<&'static phones::Region>, now: i64) -> bool {
651 let roots: Vec<PathBuf> = std::iter::once(root()).chain(crate::share::texts_copies()).collect();
656 let requests = requests_from(&roots, seal);
657 if requests.iter().all(|r| r.phone != here) {
658 return false;
659 }
660 let ledger_path = private().join(rules::LEDGER);
661 let mut said = Vec::new();
662 let mut to_send = Vec::new();
663 let claimed = sioul_core::filelock::with_lock(&ledger_path, || -> Result<(), String> {
664 let ledger = rules::Ledger::open(&private(), now)?;
665 for r in &requests {
666 let verdict = rules::verdict(r, here, now, &ledger, region);
667 let (state, reason) = match verdict {
668 Verdict::NotMine | Verdict::Decided => continue,
669 Verdict::Send => {
670 rules::record(&private(), &Claim { key: r.key.clone(), at: now, state: "claimed".into(), to: phones::key(&r.to, region), hash: rules::words_hash(&r.body) })?;
671 to_send.push(r.clone());
672 continue;
673 }
674 Verdict::Expired => ("expired", ""),
675 Verdict::Clocks => ("clocks", ""),
676 Verdict::BeforeMark => ("refused", "before-mark"),
677 Verdict::Refused(why) => ("refused", why),
678 };
679 rules::record(&private(), &Claim { key: r.key.clone(), at: now, state: state.into(), ..Claim::default() })?;
680 said.push(Outcome { key: r.key.clone(), state: state.into(), at: now, reason: reason.into(), ..Outcome::default() });
681 }
682 Ok(())
683 });
684 if let Err(e) = claimed {
685 eprintln!("sioul: texts: {e}");
687 return say(here, seal, &said);
688 }
689 let texts = if to_send.is_empty() { Vec::new() } else { rules::read_logs(&root(), seal) };
690 for r in to_send {
691 let sub = if r.sub >= 0 { r.sub } else { rules::last_sim(&rules::conversation_id(&[phones::key(&r.to, region)]), here, &texts) };
693 let answer = crate::steps::java("texts-send", &json!({ "key": r.key, "to": phones::key(&r.to, region), "body": r.body, "sub": sub }).to_string());
694 if answer["handed"] == true {
695 let _ = rules::record(&private(), &Claim { key: r.key.clone(), at: now_ms(), state: "handed".into(), ..Claim::default() });
696 said.push(Outcome { key: r.key.clone(), state: "handed".into(), at: now_ms(), ..Outcome::default() });
697 eprintln!("sioul: texts: one handed to Android ({} part(s))", answer["parts"].as_i64().unwrap_or(1));
698 } else if answer.is_null() {
699 eprintln!("sioul: texts: Android's answer did not come; the text is looked for later");
701 } else {
702 let reason = answer["reason"].as_str().unwrap_or("other").to_string();
703 let _ = rules::record(&private(), &Claim { key: r.key.clone(), at: now_ms(), state: "done".into(), ..Claim::default() });
704 said.push(Outcome { key: r.key.clone(), state: "failed".into(), at: now_ms(), reason, ..Outcome::default() });
705 }
706 }
707 say(here, seal, &said)
708}
709
710fn look_for_lost(here: &str, seal: &Seal, region: Option<&phones::Region>, now: i64) -> bool {
714 let Ok(ledger) = rules::Ledger::open(&private(), now) else { return false };
715 let lost: Vec<&Claim> = ledger.claims.iter().filter(|c| c.state == "claimed" && ledger.state(&c.key) == Some("claimed") && now - c.at >= rules::LOOK_AFTER_MS).collect();
716 if lost.is_empty() {
717 return false;
718 }
719 let since = lost.iter().map(|c| c.at).min().unwrap_or(now) - 60_000;
720 let answer = crate::steps::java("texts-find", &json!({ "since": since }).to_string());
721 if answer["allowed"] != true {
722 return false;
723 }
724 let sent: Vec<Raw> = raws(&answer);
725 let mut said = Vec::new();
726 for claim in lost {
727 let found = sent
728 .iter()
729 .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"), region) == claim.to)
730 .map(|r| format!("sms-{}", int(&r.fields, "_id")));
731 if let Some(outcome) = rules::doubt_after(claim, found, now) {
732 let _ = rules::record(&private(), &Claim { key: claim.key.clone(), at: now, state: "done".into(), ..Claim::default() });
733 said.push(outcome);
734 }
735 }
736 say(here, seal, &said)
737}
738
739fn light_step(here: &str, seal: &Seal, region: Option<&'static phones::Region>) -> bool {
743 sioul_core::filelock::with_lock(&private().join(".step"), || {
744 let now = now_ms();
745 let mut changed = copy_results(here, seal, now);
746 changed |= decide(here, seal, region, now);
747 changed |= look_for_lost(here, seal, region, now);
748 changed
749 })
750}
751
752fn heavy_step(here: &str, seal: &Seal, vault: &Vault, region: Option<&phones::Region>) -> bool {
756 sioul_core::filelock::with_lock(&private().join(".import"), || {
757 let now = now_ms();
758 let (texts_changed, plugged) = changes(true);
759 let mut changed = import_now(&Reader::load(), texts_changed, plugged, now) && import_step(here, seal, vault, region, now);
760 changed |= look_again(here, seal, vault, region, now);
761 changed
762 })
763}
764
765fn changes(clear: bool) -> (bool, bool) {
771 let answer = crate::steps::java("texts-changes", &json!({ "clear": clear }).to_string());
772 (answer["changed"] != false, answer["plugged"] == true)
773}
774
775fn import_now(reader: &Reader, changed: bool, plugged: bool, now: i64) -> bool {
784 if reader.started == 0 || reader.behind { plugged } else { changed || now - reader.looked >= 86_400_000 }
785}
786
787pub(crate) fn importing_in_bulk() -> bool {
792 phone() && sealed().is_some() && Reader::load().behind
793}
794
795pub(crate) fn phone_step() -> bool {
798 if !phone() {
799 return false;
800 }
801 let Some((here, seal, Some(vault))) = sealed() else { return false };
802 let region = sioul_core::reach::region(&load_config());
803 let quick = light_step(&here, &seal, region);
804 heavy_step(&here, &seal, &vault, region) || quick
805}
806
807static TRIMMED: Mutex<i64> = Mutex::new(0);
811static WORKING: AtomicBool = AtomicBool::new(false);
813
814pub(crate) fn before_exchange() {
821 let Some((here, seal, vault)) = sealed() else { return };
822 if phone() {
823 light_step(&here, &seal, sioul_core::reach::region(&load_config()));
824 } else {
825 measure_clock();
826 }
827 if vault.is_some() && !crate::steps::in_service() && !WORKING.swap(true, Ordering::AcqRel) {
828 std::thread::spawn(|| {
829 if let Some((here, seal, Some(vault))) = sealed() {
830 if phone() {
831 heavy_step(&here, &seal, &vault, sioul_core::reach::region(&load_config()));
832 } else {
833 fetch_media(&seal, &vault);
834 }
835 }
836 WORKING.store(false, Ordering::Release);
837 });
838 }
839 let now = now_ms();
840 let due = TRIMMED.lock().map(|mut last| {
841 let due = now - *last > 86_400_000;
842 if due {
843 *last = now;
844 }
845 due
846 });
847 if due.unwrap_or(false) {
848 let done = rules::trim_own(&root(), &here, &seal, now).and_then(|_| rules::trim_others(&root(), &here, &seal, now));
849 if phone() {
850 let _ = rules::trim_ledger(&private(), now);
851 }
852 if let Err(e) = done {
853 eprintln!("sioul: texts: {e}");
854 }
855 }
856}
857
858fn media_path(hash: &str) -> PathBuf {
862 root().join(rules::MEDIA).join(hash.chars().filter(char::is_ascii_hexdigit).collect::<String>())
863}
864
865fn here_media(hash: &str) -> bool {
866 !hash.is_empty() && media_path(hash).exists()
867}
868
869static FETCHED: Mutex<(Vec<(u64, u128)>, bool)> = Mutex::new((Vec::new(), true));
872
873fn fetch_media(seal: &Seal, vault: &Vault) {
877 let (folder, key) = vault;
878 let mut logs: Vec<(u64, u128)> = std::fs::read_dir(root().join(rules::LOG))
879 .into_iter()
880 .flatten()
881 .filter_map(|entry| entry.ok()?.metadata().ok())
882 .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())))
883 .collect();
884 logs.sort_unstable();
885 if FETCHED.lock().is_ok_and(|seen| seen.0 == logs && !seen.1) {
886 return;
887 }
888 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();
889 wanted.sort();
890 wanted.dedup();
891 let mut budget = rules::MEDIA_BATCH;
892 let (mut brought, mut left) = (0, false);
893 for (hash, size) in wanted {
894 if size > budget && budget < rules::MEDIA_BATCH {
895 left = true;
896 break;
897 }
898 let incoming = private().join("incoming").join(&hash);
899 if let Some(dir) = incoming.parent() {
900 let _ = std::fs::create_dir_all(dir);
901 }
902 if sioul_sync::blobs::get(folder, key, &hash, &incoming).is_err() {
903 left = true;
904 continue;
905 }
906 let bytes = std::fs::read(&incoming);
907 let _ = std::fs::remove_file(&incoming);
908 let Ok(bytes) = bytes else {
909 left = true;
910 continue;
911 };
912 if let Err(e) = write_whole(&media_path(&hash), &seal.seal_bytes(&bytes)) {
913 eprintln!("sioul: texts: {e}");
914 left = true;
915 break;
916 }
917 budget = budget.saturating_sub(bytes.len() as u64);
918 brought += 1;
919 }
920 if let Ok(mut seen) = FETCHED.lock() {
921 *seen = (logs, left);
922 }
923 if brought > 0 {
924 eprintln!("sioul: texts: {brought} media file(s) brought");
925 }
926}
927
928fn opened_folder() -> PathBuf {
930 sioul_core::config::cache_dir().join(rules::FOLDER)
931}
932
933fn open_media(hash: &str, ct: &str) -> Option<PathBuf> {
935 let path = media_path(hash);
936 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");
937 let target = opened_folder().join(format!("{}.{extension}", path.file_name()?.to_string_lossy()));
938 if target.exists() {
940 return Some(target);
941 }
942 let (_, seal, _) = sealed()?;
943 let plain = seal.open_bytes(&std::fs::read(&path).ok()?)?;
944 write_whole(&target, &plain).ok()?;
945 Some(target)
946}
947
948#[derive(Debug, Clone, Default, Serialize, Deserialize)]
954struct Clock {
955 #[serde(default)]
956 phones: BTreeMap<String, Gap>,
957}
958
959#[derive(Debug, Clone, Default, Serialize, Deserialize)]
960struct Gap {
961 seen: i64,
963 least: i64,
965 at: i64,
966}
967
968impl Clock {
969 fn path() -> PathBuf {
970 private().join("clock.json")
971 }
972}
973
974fn measure_clock() {
979 static LOOKED: Mutex<i64> = Mutex::new(0);
981 let now = now_ms();
982 if let Ok(mut looked) = LOOKED.lock() {
983 if now - *looked < 30_000 {
984 return;
985 }
986 *looked = now;
987 }
988 let Some(phone) = sending_phone() else { return };
989 let mut clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
990 let gap = clock.phones.entry(phone.id.clone()).or_default();
991 if phone.exported <= 0 || phone.exported == gap.seen {
992 return;
993 }
994 let sample = now - phone.exported * 1000;
995 gap.seen = phone.exported;
996 if gap.at == 0 || now - gap.at > 86_400_000 || sample < gap.least {
997 gap.least = sample;
998 gap.at = now;
999 }
1000 if let Ok(text) = serde_json::to_string(&clock) {
1001 let _ = write_whole(&Clock::path(), text.as_bytes());
1002 }
1003}
1004
1005fn ahead_of(phone: &str) -> i64 {
1007 let clock: Clock = std::fs::read_to_string(Clock::path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
1008 clock.phones.get(phone).filter(|g| now_ms() - g.at <= 86_400_000).map_or(0, |g| g.least.max(0))
1009}
1010
1011fn phones() -> Vec<sioul_sync::devices::Entry> {
1015 let Some((_, Some((folder, key)))) = crate::share::vault() else { return Vec::new() };
1016 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();
1017 let chosen = choice();
1018 all.sort_by_key(|d| (d.id != chosen.phone, -d.exported));
1019 all
1020}
1021
1022fn sending_phone() -> Option<sioul_sync::devices::Entry> {
1023 if demo() && !phone() {
1024 let exported = jiff::Timestamp::now().as_second() - 120;
1026 return Some(sioul_sync::devices::Entry { id: "demo-phone".into(), name: String::new(), kind: sioul_sync::devices::PHONE.into(), exported, ..Default::default() });
1027 }
1028 phones().into_iter().next()
1029}
1030
1031fn names(senders: std::rc::Rc<Senders>) -> impl Fn(&str) -> Option<String> {
1034 move |key: &str| {
1035 let judged = senders.judge_number(key);
1036 (!judged.card.trim().is_empty()).then_some(judged.card)
1037 }
1038}
1039
1040thread_local! {
1041 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) };
1044}
1045
1046fn looks() -> (std::rc::Rc<Config>, std::rc::Rc<Senders>) {
1049 let stamp = std::fs::metadata(config_dir().join("config.toml")).ok().map(|m| (m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
1050 LOOKS.with(|cell| {
1051 let mut kept = cell.borrow_mut();
1052 if let Some((s, at, config, senders)) = kept.as_ref()
1053 && *s == stamp
1054 && at.elapsed() < std::time::Duration::from_secs(60)
1055 {
1056 return (std::rc::Rc::clone(config), std::rc::Rc::clone(senders));
1057 }
1058 let config = std::rc::Rc::new(load_config());
1059 let senders = std::rc::Rc::new(Senders::load(&config));
1060 *kept = Some((stamp, std::time::Instant::now(), std::rc::Rc::clone(&config), std::rc::Rc::clone(&senders)));
1061 (config, senders)
1062 })
1063}
1064
1065struct Kept {
1070 here: String,
1071 stamp: Vec<(PathBuf, u64, std::time::SystemTime)>,
1072 texts: std::sync::Arc<Vec<Text>>,
1073 requests: std::sync::Arc<Vec<Request>>,
1074 outcomes: std::sync::Arc<Vec<Outcome>>,
1075}
1076
1077static KEPT: Mutex<Option<Kept>> = Mutex::new(None);
1078
1079fn stamps(root: &Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
1081 let mut all = Vec::new();
1082 for store in [rules::LOG, rules::SEND, rules::OUTCOME] {
1083 for entry in std::fs::read_dir(root.join(store)).into_iter().flatten().flatten() {
1084 if let Ok(meta) = entry.metadata() {
1085 all.push((entry.path(), meta.len(), meta.modified().unwrap_or(std::time::UNIX_EPOCH)));
1086 }
1087 }
1088 }
1089 all.sort();
1090 all
1091}
1092
1093#[allow(clippy::type_complexity)]
1095fn records(here: &str, seal: &Seal) -> (std::sync::Arc<Vec<Text>>, std::sync::Arc<Vec<Request>>, std::sync::Arc<Vec<Outcome>>) {
1096 let root = root();
1097 let stamp = stamps(&root);
1098 let mut kept = KEPT.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1099 if let Some(k) = kept.as_ref().filter(|k| k.here == here && k.stamp == stamp) {
1100 return (std::sync::Arc::clone(&k.texts), std::sync::Arc::clone(&k.requests), std::sync::Arc::clone(&k.outcomes));
1101 }
1102 let texts = std::sync::Arc::new(rules::read_logs(&root, seal));
1103 let requests = std::sync::Arc::new(rules::read_requests(&root, seal));
1104 let outcomes = std::sync::Arc::new(rules::read_outcomes(&root, seal));
1105 *kept = Some(Kept { here: here.to_string(), stamp, texts: std::sync::Arc::clone(&texts), requests: std::sync::Arc::clone(&requests), outcomes: std::sync::Arc::clone(&outcomes) });
1106 (texts, requests, outcomes)
1107}
1108
1109fn view(query: &str) -> Value {
1116 let tr = tr();
1117 let Some((here, seal, _)) = sealed() else { return json!({ "can": false, "said": tr.text("texts-page-off", None), "conversations": [] }) };
1118 let Some(phone) = sending_phone() else { return json!({ "can": false, "said": tr.text("texts-page-no-phone", None), "conversations": [] }) };
1119 let (config, senders) = looks();
1120 let now = Zoned::now();
1121 let name_of = names(senders);
1122 let shared = (phone.exported > 0).then_some(phone.exported * 1000);
1123 let v = rules::Viewer { now: &now, tr, region: sioul_core::reach::region(&config), name_of: &name_of, phone_shared: shared, media_here: &here_media };
1124 let (texts, requests, _) = records(&here, &seal);
1125 let mut conversations = rules::conversations(&texts, &requests, &v);
1126 let query = query.trim();
1127 if !query.is_empty() {
1128 let mut found: BTreeMap<String, String> = BTreeMap::new();
1130 for f in rules::search(query, &texts, &requests, &[], &v, usize::MAX) {
1131 found.entry(f.conversation).or_insert_with(|| f.message.body.chars().take(80).collect());
1132 }
1133 conversations.retain(|c| found.contains_key(&c.id));
1134 for c in &mut conversations {
1135 if let Some(words) = found.get(&c.id).filter(|w| !w.is_empty()) {
1136 c.last = words.clone();
1137 }
1138 }
1139 }
1140 let mut rows = with_drafts(conversations, &sioul_core::textdraft::all(&seal), &v);
1141 if !query.is_empty()
1142 && let Value::Array(list) = &mut rows
1143 {
1144 let words = sioul_core::rules::fold(query);
1146 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)));
1147 }
1148 let shared_at = shared.map(|ms| rules::time_of_day(ms, &now)).unwrap_or_default();
1149 let mut args = sioul_core::i18n::args();
1150 args.set("time", shared_at.clone());
1151 json!({
1152 "can": true,
1153 "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)) } },
1154 "conversations": rows,
1155 "said": "",
1156 "never": tr.text("texts-page-never", None),
1157 "empty": tr.text(if query.is_empty() { "texts-page-empty" } else { "texts-search-none" }, None),
1158 })
1159}
1160
1161fn with_drafts(conversations: Vec<rules::Conversation>, drafts: &[sioul_core::textdraft::TextDraft], v: &rules::Viewer) -> Value {
1165 let waiting = |id: &str| drafts.iter().filter(|d| d.conversation == id).count();
1166 let mut out: Vec<Value> = Vec::new();
1167 let mut new: Vec<&str> = drafts.iter().map(|d| d.conversation.as_str()).filter(|id| !conversations.iter().any(|c| c.id == *id)).collect();
1168 new.sort_unstable();
1169 new.dedup();
1170 for id in new {
1171 let title = id.split(',').map(|k| (v.name_of)(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, v.region))).collect::<Vec<_>>().join(", ");
1172 out.push(json!({ "id": id, "with": [id], "title": title, "last": "", "when": "", "at": 0, "group": false, "drafts": waiting(id) }));
1173 }
1174 for c in conversations {
1175 let mut row = serde_json::to_value(&c).unwrap_or_default();
1176 row["drafts"] = json!(waiting(&c.id));
1177 out.push(row);
1178 }
1179 Value::Array(out)
1180}
1181
1182fn conversation(id: &str) -> Value {
1184 let Some((here, seal, _)) = sealed() else { return json!({ "messages": [] }) };
1185 let (config, senders) = looks();
1186 let now = Zoned::now();
1187 let name_of = names(senders);
1188 let phone = sending_phone();
1189 let shared = phone.as_ref().filter(|p| p.exported > 0).map(|p| p.exported * 1000);
1190 let region = sioul_core::reach::region(&config);
1191 let v = rules::Viewer { now: &now, tr: tr(), region, name_of: &name_of, phone_shared: shared, media_here: &here_media };
1192 let (texts, requests, outcomes) = records(&here, &seal);
1193 let messages = rules::messages(id, &texts, &requests, &outcomes, &v);
1194 let with: Vec<String> = id.split(',').map(str::to_string).collect();
1195 let title = with.iter().map(|k| name_of(k).unwrap_or_else(|| sioul_core::calls::shown_number(k, region))).collect::<Vec<_>>().join(", ");
1196 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();
1198 json!({
1199 "id": id,
1200 "title": title,
1201 "messages": messages,
1202 "drafts": drafts,
1203 "can_write": with.len() == 1,
1204 "group": with.len() > 1,
1205 })
1206}
1207
1208fn draft_taken(id: &str) -> Value {
1211 let Some((_, seal, _)) = sealed() else { return json!({ "body": "" }) };
1212 let body = sioul_core::textdraft::all(&seal).into_iter().find(|d| d.id == id).map(|d| d.body).unwrap_or_default();
1213 let _ = sioul_core::textdraft::remove(id, &seal);
1214 json!({ "body": body })
1215}
1216
1217fn send(to: &str, body: &str, again: &str) -> (Value, bool) {
1221 let tr = tr();
1222 let Some((here, seal, _)) = sealed() else { return (json!({ "said": tr.text("texts-page-off", None), "shared": false }), false) };
1223 let Some(phone) = sending_phone() else { return (json!({ "said": tr.text("texts-page-no-phone", None), "shared": false }), false) };
1224 let region = sioul_core::reach::region(&load_config());
1225 if let Some(why) = rules::refusal(to, body, region) {
1226 return (json!({ "said": tr.text(&format!("texts-refused-{why}"), None), "shared": false }), false);
1227 }
1228 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(), device: String::new() };
1229 match rules::append(&rules::own_file(&root(), rules::SEND, &here), std::slice::from_ref(&request), &seal, None) {
1230 Ok(_) => {
1231 crate::share::texts_now();
1233 (json!({ "said": "", "shared": true, "key": request.key }), true)
1234 }
1235 Err(e) => (json!({ "said": e, "shared": false }), false),
1236 }
1237}
1238
1239fn again(key: &str) -> (Value, bool) {
1241 let Some((_, seal, _)) = sealed() else { return (json!({ "said": tr().text("texts-page-off", None), "shared": false }), false) };
1242 match rules::read_requests(&root(), &seal).into_iter().find(|r| r.key == key) {
1243 Some(old) => send(&old.to, &old.body, key),
1244 None => (json!({ "said": "", "shared": false }), false),
1245 }
1246}
1247
1248fn parts(body: &str) -> Value {
1250 let (count, gsm) = rules::parts(body);
1251 let mut args = sioul_core::i18n::args();
1252 args.set("count", i64::try_from(count).unwrap_or(0));
1253 args.set("characters", i64::try_from(body.chars().count()).unwrap_or(0));
1254 let said = if count == 0 { String::new() } else { tr().text(if gsm { "texts-parts" } else { "texts-parts-unicode" }, Some(&args)) };
1255 json!({ "parts": count, "said": said })
1256}
1257
1258fn size_words(bytes: u64) -> String {
1260 sioul_core::view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX))
1261}
1262
1263pub(crate) fn kept_words() -> String {
1266 let Some((here, seal, _)) = sealed() else { return String::new() };
1267 let (texts, _, _) = records(&here, &seal);
1268 if texts.is_empty() {
1269 return String::new();
1270 }
1271 let (count, bytes) = rules::kept(&texts, &here_media);
1272 let mut args = sioul_core::i18n::args();
1273 args.set("count", i64::try_from(count).unwrap_or(0));
1274 args.set("size", size_words(bytes));
1275 tr().text("texts-kept", Some(&args))
1276}
1277
1278fn setup() -> Value {
1285 let here = sioul_sync::share::Here::load(&state_dir());
1286 let android = cfg!(target_os = "android");
1287 let state = if android { crate::steps::java("texts-state", "{}") } else { Value::Null };
1288 let mut reader = Reader::load();
1289 if android && state["read"] == true && reader.measured == 0 {
1291 let answer = crate::steps::java("texts-ids", &json!({ "measure": true }).to_string());
1292 if answer["allowed"] == true {
1293 reader.measure(&answer, now_ms());
1294 let _ = reader.save();
1295 }
1296 }
1297 let tr = tr();
1298 let mut args = sioul_core::i18n::args();
1299 args.set("count", i64::try_from(reader.total_texts).unwrap_or(0));
1300 args.set("size", size_words(reader.total_media));
1301 args.set("done", i64::try_from(reader.done_texts).unwrap_or(0));
1302 args.set("done_size", size_words(reader.done_media));
1303 let goes_now = !android || (reader.started > 0 && !reader.behind) || changes(false).1;
1305 let progress = match (reader.measured, reader.done_texts) {
1306 (0, _) => String::new(),
1307 (_, 0) if goes_now => tr.text("texts-setup-holds", Some(&args)),
1308 (_, 0) => format!("{} {}", tr.text("texts-setup-holds", Some(&args)), tr.text("texts-setup-charging", None)),
1309 (_, done) if done >= reader.total_texts => tr.text("texts-setup-done", Some(&args)),
1310 _ if goes_now => tr.text("texts-setup-progress", Some(&args)),
1311 _ => tr.text("texts-setup-progress-charging", Some(&args)),
1312 };
1313 let choices: Vec<Value> = CAPS_MB
1314 .iter()
1315 .map(|mb| {
1316 let mut args = sioul_core::i18n::args();
1317 args.set("size", size_words(mb << 20));
1318 json!({ "value": mb.to_string(), "label": tr.text("texts-setup-cap-value", Some(&args)) })
1319 })
1320 .collect();
1321 json!({
1322 "android": android,
1323 "state": state,
1324 "on": here.shares(rules::PART, &load_config()),
1325 "sharing": here.folder.is_some(),
1326 "progress": progress,
1327 "importing": reader.measured > 0 && reader.done_texts < reader.total_texts,
1328 "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 },
1329 })
1330}
1331
1332fn set_cap(value: &str) -> Result<(), String> {
1334 let mb: u64 = value.trim().parse().map_err(|_| format!("{value}?"))?;
1335 let path = choice_path();
1336 sioul_core::filelock::with_lock(&path, || {
1337 let mut chosen = choice();
1338 chosen.media_cap_mb = (mb != rules::MEDIA_CAP_MB).then_some(mb.clamp(1, sioul_sync::blobs::LARGEST >> 20));
1339 write_whole(&path, toml::to_string(&chosen).map_err(|e| e.to_string())?.as_bytes())
1340 })
1341}
1342
1343pub(crate) fn call(verb: &str, json: &str) -> (String, bool) {
1350 let asked: Value = serde_json::from_str(json).unwrap_or_default();
1351 let text = |key: &str| asked[key].as_str().unwrap_or_default().to_string();
1352 let (answer, shared) = match verb {
1353 "view" => (view(&text("query")), false),
1354 "conversation" => (conversation(&text("id")), false),
1355 "send" => send(&text("to"), &text("body"), ""),
1356 "again" => again(&text("key")),
1357 "parts" => (parts(&text("body")), false),
1358 "draft-use" | "draft-discard" => (draft_taken(&text("id")), false),
1360 "media" => {
1361 let url = open_media(&text("hash"), &text("ct")).map(|p| crate::backend::file_url(&p)).unwrap_or_default();
1362 (json!({ "url": url }), false)
1363 }
1364 "save-media" => {
1365 let target = crate::backend::local_path(&text("path"));
1366 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());
1367 (json!({ "saved": saved }), false)
1368 }
1369 "close" => {
1370 let _ = std::fs::remove_dir_all(opened_folder());
1371 (json!({}), false)
1372 }
1373 "setup" => (setup(), false),
1374 "ask" => {
1375 crate::steps::java("texts-ask", "{}");
1376 (setup(), false)
1377 }
1378 "part" => {
1379 let problem = crate::share::set_part(rules::PART, asked["on"].as_bool().unwrap_or(false));
1380 let mut tab = setup();
1381 tab["said"] = json!(problem);
1382 (tab, problem.is_empty())
1383 }
1384 "cap" => {
1385 let problem = set_cap(&text("value")).err().unwrap_or_default();
1386 let mut tab = setup();
1387 tab["said"] = json!(problem);
1388 (tab, false)
1389 }
1390 _ => (json!({}), false),
1391 };
1392 (answer.to_string(), shared)
1393}
1394
1395pub(crate) fn readable() -> bool {
1397 sealed().is_some() && sending_phone().is_some()
1398}
1399
1400pub(crate) fn conversation_of(numbers: &[String]) -> String {
1403 if sealed().is_none() {
1404 return String::new();
1405 }
1406 let region = sioul_core::reach::region(&load_config());
1407 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()
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412 use super::*;
1413
1414 const NOW: i64 = 1_791_446_400_000;
1416
1417 fn part(key: &str, kind: &str, part: i64, parts: i64, code: i64, uri: &str, status: i64, at: i64) -> PartResult {
1418 PartResult { key: key.into(), kind: kind.into(), part, parts, code, uri: uri.into(), status, at }
1419 }
1420
1421 #[test]
1425 fn requests_are_read_from_their_small_files_too_each_once() {
1426 let base = std::env::temp_dir().join(format!("sioul-texts-copies-{}", std::process::id()));
1427 let _ = std::fs::remove_dir_all(&base);
1428 let seal = Seal::Key(TextSeal::new(&[3u8; 32]));
1429 let desk = "0f8fad5b-d9cb-469f-a165-70867728950e";
1430 let request = |key: &str| Request { key: key.into(), phone: "phone".into(), to: "0199001234".into(), body: "On my way".into(), sub: -1, written: NOW, ahead: 0, again: String::new(), device: String::new() };
1431 let (records, folder, fetched) = (base.join("records"), base.join("folder"), base.join("fetched"));
1432 rules::append(&rules::own_file(&records, rules::SEND, desk), &[request("a")], &seal, None).unwrap();
1433 rules::append(&rules::own_file(&folder, rules::SEND, desk), &[request("a"), request("b")], &seal, None).unwrap();
1434 rules::append(&rules::own_file(&fetched, rules::SEND, desk), &[request("a"), request("b"), request("c")], &seal, None).unwrap();
1435 let read = requests_from(&[records.clone(), folder, fetched], &seal);
1436 assert_eq!(read.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), ["a", "b", "c"]);
1437 assert!(read.iter().all(|r| r.device == desk), "{read:?}");
1438 assert_eq!(requests_from(&[records, base.join("none")], &seal).len(), 1);
1440 let _ = std::fs::remove_dir_all(&base);
1441 }
1442
1443 #[test]
1444 fn a_text_s_parts_make_one_outcome_each_and_delivered_only_when_reported() {
1445 let results = [
1446 part("a", "sent", 0, 2, -1, "content://sms/414", -1, NOW),
1447 part("a", "sent", 1, 2, -1, "content://sms/414", -1, NOW + 1000),
1448 part("a", "delivered", 0, 2, -1, "", 0, NOW + 5000),
1449 part("b", "sent", 0, 1, -1, "content://sms/415", -1, NOW),
1450 part("b", "delivered", 0, 1, -1, "", 0, NOW + 3000),
1451 part("c", "sent", 0, 1, 4, "", -1, NOW),
1452 part("d", "sent", 0, 2, -1, "content://sms/416", -1, NOW),
1453 ];
1454 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();
1455 assert_eq!(said, [
1456 ("a".to_string(), "sent".to_string(), "sms-414".to_string()),
1457 ("b".into(), "sent".into(), "sms-415".into()),
1458 ("b".into(), "delivered".into(), String::new()),
1459 ("c".into(), "failed".into(), "no-service".into()),
1460 ], "a: one part of two delivered, no word; d: one part of two sent, not yet");
1461 }
1462
1463 #[test]
1468 fn what_java_reads_is_kept_whole_and_read_here() {
1469 let json = r#"{"texts":[
1470 {"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}},
1471 {"kind":"mms","fields":{"_id":31,"thread_id":9,"date":1791446340,"msg_box":1,"m_type":132,"sub_id":1},
1472 "addrs":[{"address":"+33465710042","type":137,"charset":106},{"address":"insert-address-token","type":151,"charset":106}],
1473 "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}]},
1474 {"kind":"mms","fields":{"_id":32,"thread_id":11,"date":1791446350,"msg_box":1,"m_type":130},"addrs":[{"address":"+33465710042","type":137}],"parts":[]},
1475 {"kind":"sms","fields":{"_id":413,"thread_id":12,"address":"+33465710042","date":1791446360000,"type":1,"body":"Pour le groupe."}}],
1476 "threads":{"7":["0199001234"],"9":["+33465710042"],"12":["+33465710042","+33199001234"]},"sms":413,"mms":32,"allowed":true}"#;
1477 let answer: Value = serde_json::from_str(json).unwrap();
1478 let region = phones::region_named("FR");
1479 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();
1480 let texts: Vec<Text> = raws(&answer).iter().map(|r| text_of(r, &threads, region, 10 << 20)).collect();
1481 let sms = &texts[0];
1482 assert_eq!((sms.id.as_str(), sms.with.as_slice(), sms.sub, sms.direction.as_str(), sms.at), ("sms-412", &["+33199001234".to_string()][..], 2, "in", 1_791_446_340_000));
1483 assert_eq!(sms.fields.len(), 13, "every column kept");
1484 let mms = &texts[1];
1485 assert_eq!((mms.id.as_str(), mms.at, mms.with.as_slice(), mms.body.as_str(), mms.picture, mms.sub), ("mms-31", 1_791_446_340_000, &["+33465710042".to_string()][..], "Le colis est arrivé.", true, 1));
1486 assert_eq!(mms.parts.iter().map(|p| p.state.as_str()).collect::<Vec<_>>(), ["text", "here", "too-big", "text"]);
1487 assert_eq!((mms.parts[1].name.as_str(), mms.parts[2].name.as_str(), mms.parts[1].size), ("a.jpg", "b.mp4", 220_000));
1488 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");
1489 assert_eq!(mms.addrs.len(), 2);
1490 assert_eq!((texts[2].parts.len(), texts[2].with.as_slice()), (0, &["+33465710042".to_string()][..]), "nothing to download yet");
1491 assert_eq!(texts[3].with, ["+33465710042", "+33199001234"], "a group, from its conversation");
1492 assert_eq!(texts[3].from, "+33465710042");
1493 let waiting = Raw { kind: "mms".into(), parts: vec![[("ct".to_string(), json!("image/png")), ("size".to_string(), json!(-1))].into_iter().collect()], ..Raw::default() };
1494 assert_eq!(text_of(&waiting, &threads, region, 10 << 20).parts[0].state, "not-downloaded");
1495 assert_eq!(row_of("content://sms/414"), "sms-414");
1496 assert_eq!(row_of(""), "");
1497 }
1498
1499 #[test]
1503 fn the_daily_look_finds_what_was_passed_and_what_went() {
1504 let row = |id: &str| Text { id: id.into(), at: NOW, ..Text::default() };
1505 let log = vec![row("sms-1"), row("sms-2"), row("sms-4"), row("mms-3"), Text { deleted: NOW, ..row("sms-2") }, row("sms-5")];
1506 let sms: BTreeSet<i64> = [1, 3, 4, 6, 9].into();
1507 let found = gaps(&log, &sms, &BTreeSet::new(), 6, 3);
1508 assert_eq!(found.missing_sms, [3, 6], "passed while hidden; 9 is after the mark");
1509 assert!(found.missing_mms.is_empty());
1510 assert_eq!(found.gone, ["mms-3", "sms-5"], "sms-2 is marked already");
1511 }
1512
1513 #[test]
1517 fn a_heavy_import_waits_for_the_charger() {
1518 let first = Reader::default();
1519 assert!(!import_now(&first, true, false, NOW), "the whole history, on the battery: it waits");
1520 assert!(import_now(&first, false, true, NOW), "on the charger");
1521 let caught_up = Reader { started: NOW - 1_000, looked: NOW - 3_600_000, ..Reader::default() };
1522 assert!(import_now(&caught_up, true, false, NOW), "a new text, on the battery");
1523 assert!(!import_now(&caught_up, false, false, NOW) && !import_now(&caught_up, false, true, NOW), "nothing changed: Android not asked");
1524 assert!(import_now(&caught_up, false, false, NOW + 86_400_000), "the day's look: read all the same");
1525 let behind = Reader { started: NOW - 1_000, behind: true, ..caught_up.clone() };
1526 assert!(!import_now(&behind, true, false, NOW), "a full batch left more: on the charger only");
1527 assert!(import_now(&behind, false, true, NOW), "on the charger, batch after batch");
1528 let older: Reader = serde_json::from_str(r#"{"version":2,"sms":10,"mms":3,"started":1}"#).unwrap();
1530 assert!(!older.behind && import_now(&older, true, false, NOW));
1531 }
1532
1533 #[test]
1534 fn nothing_runs_on_a_computer_without_the_part() {
1535 if phone() || demo() {
1536 return;
1537 }
1538 assert!(!phone_step());
1539 let (answer, shared) = call("send", r#"{"to":"01 99 00 12 34","body":"Bonjour"}"#);
1540 assert!(!shared, "{answer}");
1541 }
1542}
1543