1use crate::backend::{load_config, tr};
23use jiff::Zoned;
24use serde_json::{Value, json};
25use sioul_core::appnotes::{Decision, Kind, Ledger, Posted};
26use sioul_core::calls;
27use sioul_core::config::{Config, state_dir};
28use sioul_core::everywhere::People;
29use sioul_core::phonemsgs::{self as rules, Choices, Extra, Line, Send};
30use sioul_core::porch::{By, Senders, Standing};
31use sioul_core::reach::{Clock, Who};
32use std::sync::Mutex;
33
34fn here_id() -> String {
36 sioul_sync::share::Here::load(&state_dir()).id
37}
38
39fn phone() -> bool {
42 cfg!(target_os = "android") || std::env::var_os("SIOUL_PHONE_MESSAGES").is_some()
43}
44
45pub(crate) fn note(json: &str, posted: &Posted, kind: &Kind, who: Option<Who>, listed: bool, decision: &Decision) -> bool {
54 if !phone() {
55 return false;
56 }
57 let path = Choices::default_path();
58 let mut choices = Choices::load(&path);
59 if posted.sms_app && choices.sms_app != posted.package {
61 note_sms_app(&posted.package);
62 choices.sms_app = posted.package.clone();
63 }
64 let config = load_config();
65 let here = sioul_sync::share::Here::load(&state_dir());
66 if here.id.is_empty() || here.folder.is_none() || !here.shares(rules::PART, &config) {
67 return false;
68 }
69 let send = choices.send(&posted.package, posted.sms_app);
70 if send == Send::Off {
71 return false;
72 }
73 let extra = Extra::read(json);
74 let words = sioul_core::words::Words::of(&config);
75 let now_ms = jiff::Timestamp::now().as_millisecond();
76 let notice = rules::Notice { posted, extra: &extra, kind, who, listed, decision, send, now_ms, region: sioul_core::reach::region(&config), words: &words };
77 let lines = rules::lines_of(¬ice);
78 if lines.is_empty() {
79 return false;
80 }
81 match rules::carry_into(&calls::own_file(&rules::folder(), rules::LOG, &here.id), &lines, now_ms) {
82 Ok(0) => false,
83 Ok(written) => {
84 eprintln!("sioul: phone messages: {written} line(s) for your computers ({})", posted.package);
86 true
87 }
88 Err(e) => {
89 eprintln!("sioul: phone messages: {e}");
90 false
91 }
92 }
93}
94
95fn note_sms_app(package: &str) {
98 let path = Choices::default_path();
99 let noted = sioul_core::filelock::with_lock(&path, || {
100 let mut kept = Choices::load(&path);
101 if kept.sms_app == package {
102 return Ok(());
103 }
104 kept.sms_app = package.to_string();
105 kept.save(&path)
106 });
107 if let Err(e) = noted {
108 eprintln!("sioul: phone messages: {e}");
109 }
110}
111
112static TRIMMED: Mutex<i64> = Mutex::new(0);
114
115pub(crate) fn before_exchange() {
119 let root = rules::folder();
120 if !root.is_dir() {
121 return;
122 }
123 let now = jiff::Timestamp::now().as_millisecond();
124 let due = TRIMMED.lock().map(|mut last| {
125 let due = now - *last > 86_400_000;
126 if due {
127 *last = now;
128 }
129 due
130 });
131 if !due.unwrap_or(false) {
132 return;
133 }
134 let here = here_id();
135 if here.is_empty() {
136 return;
137 }
138 if let Err(e) = rules::trim_own(&root, &here, now).and_then(|_| rules::trim_others(&root, &here, now)) {
139 eprintln!("sioul: phone messages: {e}");
140 }
141}
142
143fn who_now(senders: &Senders, key: &str) -> Option<Who> {
148 if key.is_empty() {
149 return None;
150 }
151 let judged = senders.judge_number(key);
152 (judged.by != By::Default).then_some(judged.who)
153}
154
155fn column_at(clock: &Clock, at_ms: i64, now: &Zoned) -> String {
158 let Ok(at) = jiff::Timestamp::from_millisecond(at_ms) else { return String::new() };
159 let mode = clock.mode(&at.to_zoned(now.time_zone().clone()));
160 sioul_core::attention::Now::of(&mode).times.first().map_or(String::new(), |c| c.id().to_string())
161}
162
163pub(crate) fn view() -> String {
167 let empty = || json!({ "lines": [] }).to_string();
168 let root = rules::folder();
169 if cfg!(target_os = "android") || !root.join(rules::LOG).is_dir() || !crate::share::shares(rules::PART) {
170 return empty();
171 }
172 let now = Zoned::now();
173 let all = rules::read_logs(&root, now.timestamp().as_millisecond());
174 if all.is_empty() {
175 return empty();
176 }
177 let config = load_config();
178 let lines = porch_lines(&config, &all, &now);
179 let (tel, sms) = crate::calls::link_apps();
180 json!({ "lines": lines, "links": { "tel": tel, "sms": sms }, "texts": crate::texts::readable() }).to_string()
182}
183
184fn porch_lines(config: &Config, all: &[Line], now: &Zoned) -> Vec<rules::PorchLine> {
185 let here = here_id();
186 let senders = Senders::load(config);
187 let attention = sioul_core::attention::Attention::of(config);
188 let moment = crate::hours::attention_now();
189 let people = People::load(&People::default_path());
190 let clock = Clock::load(config, now);
191 let region = senders.region();
192 let shows = |line: &Line| {
193 let always = line.always || (!line.key.is_empty() && people.admits_number(&line.key, region));
194 rules::reaches_now(&attention, line, who_now(&senders, &line.key), always, &moment)
195 };
196 let name_of = |key: &str| {
197 let judged = senders.judge_number(key);
198 (!judged.card.trim().is_empty()).then_some(judged.card)
199 };
200 let phones = crate::calls::phone_names();
201 let phone = |device: &str| calls::phone_words(tr(), device, &here, &phones, cfg!(target_os = "android"));
202 let column = |at: i64| column_at(&clock, at, now);
203 let lister = rules::Lister { now, tr: tr(), region, name_of: &name_of, shows: &shows, phone: &phone, column: &column };
204 rules::lines(all, &calls::read_seen(&rules::folder()), &lister)
205}
206
207pub(crate) fn history(numbers: &[String]) -> Vec<String> {
211 let root = rules::folder();
212 if numbers.is_empty() || !root.join(rules::LOG).is_dir() || !crate::share::shares(rules::PART) {
213 return Vec::new();
214 }
215 let config = load_config();
216 let region = sioul_core::reach::region(&config);
217 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();
218 if keys.is_empty() {
219 return Vec::new();
220 }
221 let now = Zoned::now();
222 let all = rules::read_logs(&root, now.timestamp().as_millisecond());
223 let (none, every, nowhere, unknown) = (|_: &str| None, |_: &Line| true, |_: &str| String::new(), |_: i64| String::new());
224 let lister = rules::Lister { now: &now, tr: tr(), region, name_of: &none, shows: &every, phone: &nowhere, column: &unknown };
225 rules::history(&all, &keys, &lister)
226}
227
228fn setup() -> Value {
236 let config = load_config();
237 let here = sioul_sync::share::Here::load(&state_dir());
238 if cfg!(target_os = "android")
240 && let Some(sms) = crate::steps::java("sms-app", "{}").as_str().filter(|p| !p.is_empty())
241 {
242 note_sms_app(sms);
243 }
244 let choices = Choices::load(&Choices::default_path());
245 let ledger = Ledger::load(&Ledger::default_path());
246 let tr = tr();
247 let text = |id: &str| tr.text(id, None);
248 let mut apps: Vec<(String, String)> = ledger.apps.iter().map(|(package, seen)| (package.clone(), seen.label.clone())).collect();
249 for (package, chosen) in &choices.app {
250 if !apps.iter().any(|(p, _)| p == package) {
251 apps.push((package.clone(), if chosen.label.is_empty() { package.clone() } else { chosen.label.clone() }));
252 }
253 }
254 apps.retain(|(package, _)| package != sioul_core::appnotes::OWN);
255 apps.sort_by_cached_key(|(package, label)| (*package != choices.sms_app, sioul_core::text::fold(label).into_iter().collect::<String>()));
256 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();
257 let apps: Vec<Value> = apps
258 .into_iter()
259 .map(|(package, label)| {
260 let help = if package == choices.sms_app { text("phonemsgs-setup-sms") } else { String::new() };
261 json!({ "key": format!("app.{package}"), "kind": "choice", "label": if label.is_empty() { package.clone() } else { label }, "help": help, "value": choices.send(&package, false).id(), "choices": offered })
262 })
263 .collect();
264 json!({
265 "android": cfg!(target_os = "android"),
266 "sharing": here.folder.is_some(),
267 "part": { "key": "part", "kind": "bool", "label": text("phonemsgs-setup-part"), "help": text("share-part-phone-messages-carries"), "value": here.shares(rules::PART, &config), "choices": [] },
268 "apps": apps,
269 })
270}
271
272fn set(package: &str, value: &str) -> Result<(), String> {
275 let Some(send) = Send::read(value) else { return Ok(()) };
276 let path = Choices::default_path();
277 sioul_core::filelock::with_lock(&path, || {
278 let mut choices = Choices::load(&path);
279 let sms = package == choices.sms_app;
280 let send = if Send::Usual.resolved(sms) == send { Send::Usual } else { send };
281 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();
282 choices.set(package, send, &label);
283 choices.save(&path)
284 })
285}
286
287pub(crate) fn call(verb: &str, json: &str) -> (String, bool) {
292 let asked: Value = serde_json::from_str(json).unwrap_or_default();
293 let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
294 let answer = |said: String, shared: bool, at: i64| (json!({ "said": said, "shared": shared, "at": at }).to_string(), shared);
295 match verb {
296 "view" => (view(), false),
297 "seen" => {
299 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();
300 match calls::mark_seen(&rules::folder(), &here_id(), &ids, jiff::Timestamp::now().as_millisecond()) {
301 Ok(at) => answer(tr().text("phonemsgs-seen-said", None), true, at),
302 Err(e) => answer(e, false, 0),
303 }
304 }
305 "unseen" => match calls::unmark_seen(&rules::folder(), &here_id(), asked["at"].as_i64().unwrap_or(0)) {
306 Ok(gone) => answer(String::new(), gone, 0),
307 Err(e) => answer(e, false, 0),
308 },
309 "block" => {
311 let number = text("number");
312 if number.is_empty() {
313 return answer(String::new(), false, 0);
314 }
315 match sioul_core::porch::set_standing(&load_config(), &format!("{}{number}", sioul_core::porch::TEL), Standing::Blocked) {
316 Ok(()) => answer(tr().text("phonemsgs-blocked-said", None), true, 0),
317 Err(e) => answer(e, false, 0),
318 }
319 }
320 "setup" => (setup().to_string(), false),
321 "set" => {
322 let package = text("key");
323 if let Err(e) = set(package.strip_prefix("app.").unwrap_or(&package), &text("value")) {
324 eprintln!("sioul: phone messages: {e}");
325 }
326 (setup().to_string(), false)
327 }
328 "part" => {
329 let on = asked["on"].as_bool().unwrap_or(false);
330 let problem = crate::share::set_part(rules::PART, on);
331 let mut tab = setup();
332 tab["said"] = json!(problem);
333 (tab.to_string(), problem.is_empty())
334 }
335 _ => (json!({}).to_string(), false),
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
346 fn a_computer_writes_nothing_and_lists_nothing_of_its_own() {
347 if phone() || rules::folder().join(rules::LOG).is_dir() {
348 return;
349 }
350 assert_eq!(view(), json!({ "lines": [] }).to_string());
351 assert!(history(&["+33199001234".to_string()]).is_empty());
352 let posted = Posted { package: "foundation.e.message".into(), sms_app: true, ..Posted::default() };
353 assert!(!note("{}", &posted, &Kind::Untouched, None, false, &Decision::through(sioul_core::appnotes::Why::Allowed)));
354 }
355}