1use crate::backend::{load_config, tr};
24use jiff::Zoned;
25use serde_json::{Value, json};
26use sioul_core::appnotes::{Decision, Kind, Ledger, Posted};
27use sioul_core::calls;
28use sioul_core::config::{Config, state_dir};
29use sioul_core::everywhere::People;
30use sioul_core::phonemsgs::{self as rules, Choices, Extra, Line, Send};
31use sioul_core::porch::{By, Senders, Standing};
32use sioul_core::reach::{Clock, Who};
33use std::sync::Mutex;
34
35fn here_id() -> String {
37 sioul_sync::share::Here::load(&state_dir()).id
38}
39
40fn phone() -> bool {
43 cfg!(target_os = "android") || std::env::var_os("SIOUL_PHONE_MESSAGES").is_some()
44}
45
46pub(crate) fn note(json: &str, posted: &Posted, kind: &Kind, who: Option<Who>, listed: bool, decision: &Decision) -> bool {
55 if !phone() {
56 return false;
57 }
58 let path = Choices::default_path();
59 let mut choices = Choices::load(&path);
60 if posted.sms_app && choices.sms_app != posted.package {
62 note_sms_app(&posted.package);
63 choices.sms_app = posted.package.clone();
64 }
65 let config = load_config();
66 let here = sioul_sync::share::Here::load(&state_dir());
67 if here.id.is_empty() || here.folder.is_none() || !here.shares(rules::PART, &config) {
68 return false;
69 }
70 let send = choices.send(&posted.package, posted.sms_app);
71 if send == Send::Off {
72 return false;
73 }
74 let extra = Extra::read(json);
75 let words = sioul_core::words::Words::of(&config);
76 let now_ms = jiff::Timestamp::now().as_millisecond();
77 let notice = rules::Notice { posted, extra: &extra, kind, who, listed, decision, send, now_ms, region: sioul_core::reach::region(&config), words: &words };
78 let lines = rules::lines_of(¬ice);
79 if lines.is_empty() {
80 return false;
81 }
82 crate::replies::relayed(now_ms);
84 let offered = crate::replies::offered(&here.id, posted, &lines, &extra, choices.answers(&posted.package), now_ms);
87 match rules::carry_into(&calls::own_file(&rules::folder(), rules::LOG, &here.id), &lines, now_ms) {
88 Ok(0) => offered,
89 Ok(written) => {
90 eprintln!("sioul: phone messages: {written} line(s) for your computers ({})", posted.package);
92 true
93 }
94 Err(e) => {
95 eprintln!("sioul: phone messages: {e}");
96 offered
97 }
98 }
99}
100
101fn note_sms_app(package: &str) {
104 let path = Choices::default_path();
105 let noted = sioul_core::filelock::with_lock(&path, || {
106 let mut kept = Choices::load(&path);
107 if kept.sms_app == package {
108 return Ok(());
109 }
110 kept.sms_app = package.to_string();
111 kept.save(&path)
112 });
113 if let Err(e) = noted {
114 eprintln!("sioul: phone messages: {e}");
115 }
116}
117
118static TRIMMED: Mutex<i64> = Mutex::new(0);
120
121pub(crate) fn before_exchange() {
125 let root = rules::folder();
126 if !root.is_dir() {
127 return;
128 }
129 let now = jiff::Timestamp::now().as_millisecond();
130 let due = TRIMMED.lock().map(|mut last| {
131 let due = now - *last > 86_400_000;
132 if due {
133 *last = now;
134 }
135 due
136 });
137 if !due.unwrap_or(false) {
138 return;
139 }
140 let here = here_id();
141 if here.is_empty() {
142 return;
143 }
144 if let Err(e) = rules::trim_own(&root, &here, now).and_then(|_| rules::trim_others(&root, &here, now)) {
145 eprintln!("sioul: phone messages: {e}");
146 }
147}
148
149fn who_now(senders: &Senders, key: &str) -> Option<Who> {
154 if key.is_empty() {
155 return None;
156 }
157 let judged = senders.judge_number(key);
158 (judged.by != By::Default).then_some(judged.who)
159}
160
161fn column_at(clock: &Clock, at_ms: i64, now: &Zoned) -> String {
164 let Ok(at) = jiff::Timestamp::from_millisecond(at_ms) else { return String::new() };
165 let mode = clock.mode(&at.to_zoned(now.time_zone().clone()));
166 sioul_core::attention::Now::of(&mode).times.first().map_or(String::new(), |c| c.id().to_string())
167}
168
169pub(crate) fn view() -> String {
173 let empty = || json!({ "lines": [], "texts": crate::texts::readable(), "notices": crate::texts::notices() }).to_string();
175 let root = rules::folder();
176 if cfg!(target_os = "android") || !root.join(rules::LOG).is_dir() || !crate::share::shares(rules::PART) {
177 return empty();
178 }
179 let now = Zoned::now();
180 let all = rules::read_logs(&root, now.timestamp().as_millisecond());
181 if all.is_empty() {
182 return empty();
183 }
184 let config = load_config();
185 let all: Vec<Line> = match crate::texts::folding() {
188 Some(folds) => all.into_iter().filter(|l| !(l.kind == "text" && !l.group && folds(&l.key, l.at))).collect(),
189 None => all,
190 };
191 let lines = porch_lines(&config, &all, &now);
192 let (tel, sms) = crate::calls::link_apps();
193 json!({ "lines": lines, "links": { "tel": tel, "sms": sms }, "texts": crate::texts::readable(), "notices": crate::texts::notices() }).to_string()
195}
196
197pub(crate) fn seen_for(keys: &[String]) {
201 let root = rules::folder();
202 if keys.is_empty() || !root.join(rules::LOG).is_dir() {
203 return;
204 }
205 let now = Zoned::now().timestamp().as_millisecond();
206 let seen = calls::read_seen(&root);
207 let ids: Vec<String> = rules::read_logs(&root, now).into_iter().filter(|l| l.kind == "text" && keys.contains(&l.key) && !seen.seen.contains(&l.id)).map(|l| l.id).collect();
208 if !ids.is_empty()
209 && let Err(e) = calls::mark_seen(&root, &here_id(), &ids, now)
210 {
211 eprintln!("sioul: phone messages: {e}");
212 }
213}
214
215fn line_persons(senders: &Senders, line: &Line) -> Vec<String> {
219 if !line.key.is_empty() {
220 senders.person_rows(std::slice::from_ref(&line.key), &[])
221 } else if !line.name.trim().is_empty() && !line.group {
222 senders.person_rows_named(&line.name)
223 } else {
224 Vec::new()
225 }
226}
227
228fn porch_lines(config: &Config, all: &[Line], now: &Zoned) -> Vec<rules::PorchLine> {
229 let here = here_id();
230 let senders = Senders::load(config);
231 let attention = sioul_core::attention::Attention::of(config);
232 let moment = crate::hours::attention_now();
233 let people = People::load(&People::default_path());
234 let clock = Clock::load(config, now);
235 let region = senders.region();
236 let answered = if moment.times.contains(&sioul_core::attention::Column::Pause) { sioul_core::replies::Answered::default() } else { crate::replies::answered_here(all, now) };
239 let shows = |line: &Line| {
240 let always = line.always || (!line.key.is_empty() && people.admits_number(&line.key, region));
241 let who = who_now(&senders, &line.key);
242 let persons = line_persons(&senders, line);
243 rules::reaches_now(&attention, line, who, always, &persons, &moment) || (who != Some(Who::Blocked) && answered.holds(line))
244 };
245 let name_of = |key: &str| {
246 let judged = senders.judge_number(key);
247 (!judged.card.trim().is_empty()).then_some(judged.card)
248 };
249 let phones = crate::calls::phone_names();
250 let phone = |device: &str| calls::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
251 let column = |at: i64| column_at(&clock, at, now);
252 let lister = rules::Lister { now, tr: tr(), region, name_of: &name_of, shows: &shows, phone: &phone, column: &column };
253 let mut lines = rules::lines(all, &calls::read_seen(&rules::folder()), &lister);
254 crate::replies::fill(&mut lines, all, now);
256 lines
257}
258
259pub(crate) fn history(numbers: &[String]) -> Vec<String> {
263 let root = rules::folder();
264 if numbers.is_empty() || !root.join(rules::LOG).is_dir() || !crate::share::shares(rules::PART) {
265 return Vec::new();
266 }
267 let config = load_config();
268 let region = sioul_core::reach::region(&config);
269 let keys: std::collections::BTreeSet<String> = numbers.iter().map(|n| sioul_core::phones::key(n.trim().trim_start_matches(sioul_core::porch::TEL), region)).filter(|k| sioul_core::phones::is_whole(k)).collect();
270 if keys.is_empty() {
271 return Vec::new();
272 }
273 let now = Zoned::now();
274 let all = rules::read_logs(&root, now.timestamp().as_millisecond());
275 let (none, every, nowhere, unknown) = (|_: &str| None, |_: &Line| true, |_: &str| String::new(), |_: i64| String::new());
276 let lister = rules::Lister { now: &now, tr: tr(), region, name_of: &none, shows: &every, phone: &nowhere, column: &unknown };
277 rules::history(&all, &keys, &lister)
278}
279
280fn setup() -> Value {
288 let config = load_config();
289 let here = sioul_sync::share::Here::load(&state_dir());
290 if cfg!(target_os = "android")
292 && let Some(sms) = crate::steps::java("sms-app", "{}").as_str().filter(|p| !p.is_empty())
293 {
294 note_sms_app(sms);
295 }
296 let choices = Choices::load(&Choices::default_path());
297 let ledger = Ledger::load(&Ledger::default_path());
298 let tr = tr();
299 let text = |id: &str| tr.text(id, None);
300 let mut apps: Vec<(String, String)> = ledger.apps.iter().map(|(package, seen)| (package.clone(), seen.label.clone())).collect();
301 for (package, chosen) in &choices.app {
302 if !apps.iter().any(|(p, _)| p == package) {
303 apps.push((package.clone(), if chosen.label.is_empty() { package.clone() } else { chosen.label.clone() }));
304 }
305 }
306 apps.retain(|(package, _)| package != sioul_core::appnotes::OWN);
307 apps.sort_by_cached_key(|(package, label)| (*package != choices.sms_app, sioul_core::text::fold(label).into_iter().collect::<String>()));
308 let offered: Vec<Value> = [Send::Off, Send::Who, Send::Words].iter().map(|s| json!({ "value": s.id(), "label": text(&format!("phonemsgs-send-{}", s.id())) })).collect();
309 let apps: Vec<Value> = apps
311 .into_iter()
312 .flat_map(|(package, label)| {
313 let help = if package == choices.sms_app { text("phonemsgs-setup-sms") } else { String::new() };
314 let send = choices.send(&package, false);
315 let mut rows = vec![json!({ "key": format!("app.{package}"), "kind": "choice", "label": if label.is_empty() { package.clone() } else { label }, "help": help, "value": send.id(), "choices": offered })];
316 if send != Send::Off {
317 rows.push(json!({ "key": format!("answers.{package}"), "kind": "bool", "label": text("phonemsgs-setup-answers"), "help": text("phonemsgs-setup-answers-help"), "value": choices.answers(&package), "choices": [] }));
318 }
319 rows
320 })
321 .collect();
322 json!({
323 "android": cfg!(target_os = "android"),
324 "sharing": here.folder.is_some(),
325 "part": { "key": "part", "kind": "bool", "label": text("phonemsgs-setup-part"), "help": text("share-part-phone-messages-carries"), "value": here.shares(rules::PART, &config), "choices": [] },
326 "apps": apps,
327 })
328}
329
330fn set(package: &str, value: &str) -> Result<(), String> {
333 let Some(send) = Send::read(value) else { return Ok(()) };
334 let path = Choices::default_path();
335 sioul_core::filelock::with_lock(&path, || {
336 let mut choices = Choices::load(&path);
337 let sms = package == choices.sms_app;
338 let send = if Send::Usual.resolved(sms) == send { Send::Usual } else { send };
339 let label = Ledger::load(&Ledger::default_path()).apps.get(package).map(|a| a.label.clone()).or_else(|| choices.app.get(package).map(|a| a.label.clone())).unwrap_or_default();
340 choices.set(package, send, &label);
341 choices.save(&path)
342 })
343}
344
345fn set_answers(package: &str, on: bool) -> Result<(), String> {
347 let path = Choices::default_path();
348 sioul_core::filelock::with_lock(&path, || {
349 let mut choices = Choices::load(&path);
350 choices.set_answers(package, on);
351 choices.save(&path)
352 })
353}
354
355pub(crate) fn call(verb: &str, json: &str) -> (String, bool) {
362 let asked: Value = serde_json::from_str(json).unwrap_or_default();
363 let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
364 let answer = |said: String, shared: bool, at: i64| (json!({ "said": said, "shared": shared, "at": at }).to_string(), shared);
365 match verb {
366 "view" => (view(), false),
367 "seen" => {
369 let ids: Vec<String> = asked["ids"].as_array().map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_string)).collect()).unwrap_or_default();
370 match calls::mark_seen(&rules::folder(), &here_id(), &ids, jiff::Timestamp::now().as_millisecond()) {
371 Ok(at) => answer(tr().text("phonemsgs-seen-said", None), true, at),
372 Err(e) => answer(e, false, 0),
373 }
374 }
375 "unseen" => match calls::unmark_seen(&rules::folder(), &here_id(), asked["at"].as_i64().unwrap_or(0)) {
376 Ok(gone) => answer(String::new(), gone, 0),
377 Err(e) => answer(e, false, 0),
378 },
379 "block" => {
381 let number = text("number");
382 if number.is_empty() {
383 return answer(String::new(), false, 0);
384 }
385 match sioul_core::porch::set_standing(&load_config(), &format!("{}{number}", sioul_core::porch::TEL), Standing::Blocked) {
386 Ok(()) => answer(tr().text("phonemsgs-blocked-said", None), true, 0),
387 Err(e) => answer(e, false, 0),
388 }
389 }
390 "setup" => (setup().to_string(), false),
391 "reply" => match crate::replies::ask(&text("phone"), &text("verb"), &text("notice"), &text("app"), &text("line"), asked["words"].as_str().unwrap_or_default(), &text("again")) {
393 Ok(key) => (json!({ "key": key, "said": "", "shared": true }).to_string(), true),
394 Err(said) => answer(said, false, 0),
395 },
396 "take-back" => match crate::replies::take_back(&text("key")) {
398 Ok(()) => answer(String::new(), true, 0),
399 Err(e) => answer(e, false, 0),
400 },
401 "set" => {
402 let key = text("key");
403 let done = match key.strip_prefix("answers.") {
404 Some(package) => set_answers(package, asked["value"] == true),
405 None => set(key.strip_prefix("app.").unwrap_or(&key), &text("value")),
406 };
407 if let Err(e) = done {
408 eprintln!("sioul: phone messages: {e}");
409 }
410 (setup().to_string(), false)
411 }
412 "part" => {
413 let on = asked["on"].as_bool().unwrap_or(false);
414 let problem = crate::share::set_part(rules::PART, on);
415 let mut tab = setup();
416 tab["said"] = json!(problem);
417 (tab.to_string(), problem.is_empty())
418 }
419 _ => (json!({}).to_string(), false),
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
431 fn a_text_reads_its_sender_s_rows_by_number_or_name() {
432 use sioul_core::porch::Book;
433 let card = sioul_core::contacts::Contact { uid: "noa-1".into(), key: "/books/noa-1.vcf".into(), name: "Noa".into(), phones: vec![sioul_core::contacts::Labeled { label: String::new(), value: "+33 1 99 00 12 34".into() }], ..Default::default() };
434 let senders = Senders { contacts: std::sync::Arc::new(Book::of(&[card], None)), ..Senders::default() };
435 let line = |key: &str, name: &str, group: bool| Line { key: key.into(), name: name.into(), group, ..Line::default() };
436 assert_eq!(line_persons(&senders, &line("+33199001234", "", false)), ["person.contact:noa-1", "person.tel:+33199001234"]);
437 assert_eq!(line_persons(&senders, &line("", "Noa", false)), ["person.contact:noa-1"], "by name, the app giving no number");
438 assert!(line_persons(&senders, &line("", "Noa", true)).is_empty(), "a group's title names nobody");
439 }
440
441 #[test]
444 fn a_computer_writes_nothing_and_lists_nothing_of_its_own() {
445 if phone() || rules::folder().join(rules::LOG).is_dir() {
446 return;
447 }
448 assert_eq!(view(), json!({ "lines": [], "texts": crate::texts::readable(), "notices": crate::texts::notices() }).to_string());
450 assert!(history(&["+33199001234".to_string()]).is_empty());
451 let posted = Posted { package: "foundation.e.message".into(), sms_app: true, ..Posted::default() };
452 assert!(!note("{}", &posted, &Kind::Untouched, None, false, &Decision::through(sioul_core::appnotes::Why::Allowed)));
453 }
454}