Skip to main content

sioul_app/
phonemsgs.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The phone's messages on your computers, as this device writes and shows
5//! them (docs/android.md, "Messages on your computers"; the rules are
6//! `sioul_core::phonemsgs`).
7//!
8//! - **On the phone**, in the listener's process: after each notification
9//!   decided (`appnotes::decide`), the lines it makes are written to this
10//!   phone's own log while the part "Messages from your phone" is on here
11//!   (`note`); Java then asks the background service for a step soon, which
12//!   shares them. Rust writes the shared file; Java never does.
13//! - **On every device**, before each exchange: its own lines taken out
14//!   after their week, another's three days later (`before_exchange`).
15//! - **On a computer's Porch**, "From your phone" (`view`), while the part
16//!   is on there: each line once the phone let it through and while its
17//!   sender may reach you; Seen on any device, gone from all (`act`).
18//! - **A person's sheet**: their messages of the week (`history`).
19//! - **The phone's choices**, Settings ▸ Other apps ▸ On your computers
20//!   (`setup`, the "set" and "part" verbs of `call`).
21
22use 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
34/// This device's name in the sharing (share/here.toml), made once.
35fn here_id() -> String {
36    sioul_sync::share::Here::load(&state_dir()).id
37}
38
39/// Whether this device's listener writes lines: a phone. SIOUL_PHONE_MESSAGES
40/// makes a computer's do it too, to look at them.
41fn phone() -> bool {
42    cfg!(target_os = "android") || std::env::var_os("SIOUL_PHONE_MESSAGES").is_some()
43}
44
45// ---------------------------------------------------------------- the phone's side
46
47/// A notification decided (`appnotes::decide`, in the listener's process):
48/// its lines written to this phone's own log, when the part is on here and
49/// its app sends to your computers (`Choices`). `json` is what Java said
50/// (`sioul_core::phonemsgs::Extra` reads what `Posted` passes over); `who`
51/// and `listed` as the decision judged the sender. Whether a line was
52/// written: Java asks for a step soon then.
53pub(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    // The phone's SMS app noted, for the tab, when it changed.
60    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(&notice);
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            // Never what it says, nor who: how many, and the app.
85            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
95/// The phone's default SMS app written down, under the choices' lock: its
96/// usual is to send its words, which the tab says.
97fn 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
112/// When this device last took its old lines out (ms, its clock): once a day.
113static TRIMMED: Mutex<i64> = Mutex::new(0);
114
115/// Before an exchange, on every device that has the folder, once a day: its
116/// own lines taken out after their week, another's three days later (a
117/// phone gone for good never takes its own out).
118pub(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
143// ---------------------------------------------------------------- the Porch
144
145/// Who a number is now, as this device's lists and address books say;
146/// none when they say nothing of it (the phone's judgement stands).
147fn 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
155/// The part of the day a moment fell in, by this device's clock: a column of
156/// the matrix ("work", "sleep"…).
157fn 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
163/// The Porch's "From your phone" (PhoneMessagesSection.qml): {lines, links:
164/// {tel, sms}}; nothing on a phone (its notifications are there), nothing
165/// while the part is off here.
166pub(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    // texts: Text back opens the conversation in Sioul when texts are read on this computer.
181    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
207/// A person's messages of the week (`numbers`: theirs, as written), for
208/// their sheet (`reaches::person`): a sentence each, newest first; none
209/// where no phone shares them, or while the part is off here.
210pub(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
228// ---------------------------------------------------------------- the phone's choices
229
230/// The phone's tab, Settings ▸ This phone ▸ On your computers
231/// (PhoneMessagesSetup.qml): {android, sharing (a sharing folder is set),
232/// part, apps}, each a row as `SettingRow.qml` shows it ({key, kind, label,
233/// help, value, choices}): the part's switch here, then each app seen
234/// lately, the SMS app first, then by name.
235fn setup() -> Value {
236    let config = load_config();
237    let here = sioul_sync::share::Here::load(&state_dir());
238    // The phone's SMS app, as Android says it now (it may have changed since the listener last saw one).
239    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
272/// An app's choice written (`value`: "off", "who", "words"): what its usual
273/// says is kept as usual, so that the SMS app changed stays followed.
274fn 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
287/// What the window asks (`Sioul::phone_messages`): "view" (the Porch's
288/// section), "seen" {ids}, "unseen" {at} (within its ten seconds), "block"
289/// {number}, "setup" (the phone's tab), "set" {key: `app.<package>`, value}, "part" {on}.
290/// Answers its JSON, and whether what travels changed (to share now).
291pub(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        // In this device's own seen log, which the sharing carries: gone from every device's Porch.
298        "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        // On your blocked list, as a number: nothing of theirs leaves the phone again.
310        "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    /// On a computer without SIOUL_PHONE_MESSAGES and no phone's log here:
344    /// nothing listed, nothing written for a notification.
345    #[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}