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`), and what it offers a reply from your computers to its live
12//!   lines (`replies::offered`); Java then asks the background service for a
13//!   step soon, which shares them. Rust writes the shared files; Java never does.
14//! - **On every device**, before each exchange: its own lines taken out
15//!   after their week, another's three days later (`before_exchange`).
16//! - **On a computer's Porch**, "From your phone" (`view`), while the part
17//!   is on there: each line once the phone let it through and while its
18//!   sender may reach you; Seen on any device, gone from all (`act`).
19//! - **A person's sheet**: their messages of the week (`history`).
20//! - **The phone's choices**, Settings ▸ Other apps ▸ On your computers
21//!   (`setup`, the "set" and "part" verbs of `call`).
22
23use 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
35/// This device's name in the sharing (share/here.toml), made once.
36fn here_id() -> String {
37    sioul_sync::share::Here::load(&state_dir()).id
38}
39
40/// Whether this device's listener writes lines: a phone. SIOUL_PHONE_MESSAGES
41/// makes a computer's do it too, to look at them.
42fn phone() -> bool {
43    cfg!(target_os = "android") || std::env::var_os("SIOUL_PHONE_MESSAGES").is_some()
44}
45
46// ---------------------------------------------------------------- the phone's side
47
48/// A notification decided (`appnotes::decide`, in the listener's process):
49/// its lines written to this phone's own log, when the part is on here and
50/// its app sends to your computers (`Choices`). `json` is what Java said
51/// (`sioul_core::phonemsgs::Extra` reads what `Posted` passes over); `who`
52/// and `listed` as the decision judged the sender. Whether a line was
53/// written: Java asks for a step soon then.
54pub(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    // The phone's SMS app noted, for the tab, when it changed.
61    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(&notice);
79    if lines.is_empty() {
80        return false;
81    }
82    // The replies' ledger's mark, before any reply can answer these lines (`replies`).
83    crate::replies::relayed(now_ms);
84    // What it offers a reply from your computers, said when that changed (a Reply, a Mark as read),
85    // as the phone's switch for its app says.
86    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            // Never what it says, nor who: how many, and the app.
91            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
101/// The phone's default SMS app written down, under the choices' lock: its
102/// usual is to send its words, which the tab says.
103fn 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
118/// When this device last took its old lines out (ms, its clock): once a day.
119static TRIMMED: Mutex<i64> = Mutex::new(0);
120
121/// Before an exchange, on every device that has the folder, once a day: its
122/// own lines taken out after their week, another's three days later (a
123/// phone gone for good never takes its own out).
124pub(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
149// ---------------------------------------------------------------- the Porch
150
151/// Who a number is now, as this device's lists and address books say;
152/// none when they say nothing of it (the phone's judgement stands).
153fn 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
161/// The part of the day a moment fell in, by this device's clock: a column of
162/// the matrix ("work", "sleep"…).
163fn 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
169/// The Porch's "From your phone" (PhoneMessagesSection.qml): {lines, links:
170/// {tel, sms}}; nothing on a phone (its notifications are there), nothing
171/// while the part is off here.
172pub(crate) fn view() -> String {
173    // The notices of texts not read yet, whether or not the phone's notifications come here.
174    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    // A text the Texts page holds is one item (23 October 2026): its notice,
186    // opening it there; the SMS app's notification of it is folded into it.
187    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    // texts: Text back opens the conversation in Sioul when texts are read on this computer.
194    json!({ "lines": lines, "links": { "tel": tel, "sms": sms }, "texts": crate::texts::readable(), "notices": crate::texts::notices() }).to_string()
195}
196
197/// The phone's notifications of a conversation's texts seen, on every
198/// device (`keys`: its people's numbers' keys): its thread opened, or
199/// deleted, in Texts. The last week's, which is all a phone keeps.
200pub(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
215/// A phone's text's sender's own rows, as the phone read them (decision 16):
216/// by their number; by their name, as the phone's address book names people,
217/// when the app gave no number (review of part 6, F8).
218fn 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    // A conversation this computer answered in the last twenty minutes shows at once, as its phone
237    // lets it through, but in a pause (`replies`).
238    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    // What each line offers a reply from this computer (`replies`).
255    crate::replies::fill(&mut lines, all, now);
256    lines
257}
258
259/// A person's messages of the week (`numbers`: theirs, as written), for
260/// their sheet (`reaches::person`): a sentence each, newest first; none
261/// where no phone shares them, or while the part is off here.
262pub(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
280// ---------------------------------------------------------------- the phone's choices
281
282/// The phone's tab, Settings ▸ This phone ▸ On your computers
283/// (PhoneMessagesSetup.qml): {android, sharing (a sharing folder is set),
284/// part, apps}, each a row as `SettingRow.qml` shows it ({key, kind, label,
285/// help, value, choices}): the part's switch here, then each app seen
286/// lately, the SMS app first, then by name.
287fn setup() -> Value {
288    let config = load_config();
289    let here = sioul_sync::share::Here::load(&state_dir());
290    // The phone's SMS app, as Android says it now (it may have changed since the listener last saw one).
291    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    // Each app's choice, and under each that sends, "Answer from your computers" (on unless switched off).
310    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
330/// An app's choice written (`value`: "off", "who", "words"): what its usual
331/// says is kept as usual, so that the SMS app changed stays followed.
332fn 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
345/// "Answer from your computers" switched for an app, under the choices' lock.
346fn 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
355/// What the window asks (`Sioul::phone_messages`): "view" (the Porch's
356/// section), "seen" {ids}, "unseen" {at} (within its ten seconds), "block"
357/// {number}, "setup" (the phone's tab), "set" {key: `app.<package>` or `answers.<package>`, value}, "part" {on};
358/// "reply" {phone, verb ("reply", "read"), notice, app, line, words, again}
359/// (a reply from this computer, `replies::ask`: {key, said}), "take-back" {key}.
360/// Answers its JSON, and whether what travels changed (to share now).
361pub(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        // In this device's own seen log, which the sharing carries: gone from every device's Porch.
368        "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        // On your blocked list, as a number: nothing of theirs leaves the phone again.
380        "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        // A reply from this computer to a phone's notification, or a Mark as read: a request the phone decides once.
392        "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        // Taken back while it waits for the phone.
397        "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    /// Review of part 6, F8: a phone's text's sender's own rows, by their
428    /// number, else by their name as the phone's address book names people;
429    /// a group's, none.
430    #[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    /// On a computer without SIOUL_PHONE_MESSAGES and no phone's log here:
442    /// nothing listed, nothing written for a notification.
443    #[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        // No line; the texts' notices only where texts are read here (none in a test).
449        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}