Skip to main content

sioul_app/
replies.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Replies from your computers to your phone's notifications, on the phone
5//! (docs/android.md, "Replies from your computers"; the rules are
6//! `sioul_core::replies`). The listener's side, in its own process
7//! (AppNotes.java, Replies.java), through `sioul_replies_listener`:
8//!
9//! - **What is due** ("due"): what the background step handed over, sealed
10//!   in the phone's private replies folder, not taken yet: its key, its verb
11//!   and its notification's name, never the words.
12//! - **Checked** ("check"): the live notification Java found by that name is
13//!   still the conversation answered (`replies::check`), and whether their
14//!   newer messages crossed the reply.
15//! - **Taken** ("taken"): written and flushed before Java sends anything;
16//!   the words only then, in memory.
17//! - **What happened** ("result"): said in the private folder, never the
18//!   words; a reply handed is looked for in its app's next notification, and
19//!   said "shown" when its words are there as yours (`heard`).
20//! - **Gone** ("removed"): a relayed notification gone from the phone, said
21//!   in its live lines (`replies::went`), so that your computers no longer
22//!   offer its Reply.
23//!
24//! And for the lines the listener writes (`phonemsgs::note`): what each
25//! relayed notification offers, in this phone's live lines (`offered`).
26//!
27//! The phone's step (`phone_step`, and before each exchange): each request
28//! for this phone decided once by the texts' rules (`replies::verdict`),
29//! claimed in its private ledger and flushed, then handed to the listener;
30//! the listener's words copied into its outcomes. On a computer: a reply
31//! written there (`ask`), or taken back while it waits (`take_back`).
32
33use crate::backend::load_config;
34use serde_json::{Value, json};
35use sioul_core::appnotes::Posted;
36use sioul_core::calls;
37use sioul_core::config::state_dir;
38use sioul_core::phonemsgs::{self, Extra, Line};
39use sioul_core::replies::{self as rules, Ask, Checked, Done, Judge, Live, Outcome, Taken, Verdict};
40use sioul_core::texts::Claim;
41use sioul_sync::textseal::ReplySeal;
42use std::ffi::c_char;
43use std::path::{Path, PathBuf};
44use std::sync::Mutex;
45
46fn now_ms() -> i64 {
47    jiff::Timestamp::now().as_millisecond()
48}
49
50/// The replies' seal at rest, from the sharing key; none where sharing is not set up here.
51fn seal() -> Option<ReplySeal> {
52    let (_, vault) = crate::share::vault()?;
53    let (_, key) = vault?;
54    Some(ReplySeal::new(&key))
55}
56
57/// The states the listener may say (`replies::Done::state`); anything else is "failed".
58const STATES: &[&str] = &["handed", "gone", "other", "no-reply", "no-read", "closed", "locked", "activity", "failed"];
59
60/// A reply on its way, in the listener's process, never its words: its key,
61/// its notification's name, its words' hash (`replies::words_hash`), its
62/// conversation's key (to let their answer through: `replies::note_answered`),
63/// when it was taken, and whether its words were in the notification
64/// already before it went (`replies::shown_already`: then only a message of
65/// yours timed after it says it shown).
66#[derive(Debug, Clone)]
67struct Sent {
68    key: String,
69    notice: String,
70    hash: String,
71    talk: String,
72    taken: i64,
73    before: bool,
74}
75
76/// Checked and not taken yet: the key and whether its words were shown already (`check`).
77static CHECKED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new());
78/// Taken and not said yet.
79static TAKEN: Mutex<Vec<Sent>> = Mutex::new(Vec::new());
80/// Handed lately, looked for in its app's next notification (`heard`) for `SHOWN_WITHIN_MS`.
81static HANDED: Mutex<Vec<(Sent, i64)>> = Mutex::new(Vec::new());
82
83/// The listener's question (AppNotes.nativeReply): its verb and its JSON, a JSON answer.
84fn listener(verb: &str, json: &str) -> Value {
85    let asked: Value = serde_json::from_str(json).unwrap_or_default();
86    let key = asked["key"].as_str().unwrap_or_default();
87    let folder = rules::private_folder();
88    match verb {
89        "due" => {
90            let Some(seal) = seal() else { return json!({ "due": [] }) };
91            let due: Vec<Value> = rules::waiting(&folder, &seal).iter().map(|d| json!({ "key": d.key, "verb": d.verb, "notice": d.notice })).collect();
92            json!({ "due": due })
93        }
94        "check" => check(key, &asked["posted"]),
95        "taken" => taken(key),
96        "result" => result(key, &asked),
97        "removed" => json!({ "shared": removed(asked["package"].as_str().unwrap_or_default(), asked["key"].as_str().unwrap_or_default()) }),
98        _ => Value::Null,
99    }
100}
101
102/// The live notification Java found for one due: still the same app,
103/// notification and conversation; whether their newer messages crossed it.
104fn check(key: &str, posted: &Value) -> Value {
105    let gone = json!({ "go": false, "state": "gone" });
106    let Some(seal) = seal() else { return gone };
107    let Some(due) = rules::read_due(&rules::private_folder(), &seal).into_iter().find(|d| d.key == key) else { return gone };
108    let Ok(read) = serde_json::from_value::<Posted>(posted.clone()) else { return gone };
109    let extra = Extra::read(&posted.to_string());
110    let config = load_config();
111    let choices = sioul_core::appnotes::Choices::load(&sioul_core::appnotes::Choices::default_path());
112    let words = sioul_core::words::Words::of(&config);
113    match rules::check(&due, &read, &extra, &choices, &words, sioul_core::reach::region(&config)) {
114        Checked::Go { crossed } => {
115            // Its words there already, before it goes (an earlier reply of yours): said "shown" only by a newer message of yours.
116            let before = due.verb == "reply" && rules::shown_already(&read, &extra, &rules::words_hash(&due.words));
117            if let Ok(mut checked) = CHECKED.lock() {
118                checked.retain(|(k, _)| k != key);
119                checked.push((key.to_string(), before));
120            }
121            json!({ "go": true, "crossed": crossed })
122        }
123        Checked::Gone => gone,
124        Checked::Other => json!({ "go": false, "state": "other" }),
125    }
126}
127
128/// One taken, once: "taken" written and flushed before Java sends it; its
129/// words given to Java then, in memory. Late, said so, never sent.
130fn taken(key: &str) -> Value {
131    let Some(seal) = seal() else { return json!({ "go": false }) };
132    let now = now_ms();
133    match rules::take(&rules::private_folder(), key, &seal, now) {
134        Ok(Taken::Go(due)) => {
135            let before = CHECKED.lock().ok().and_then(|mut checked| {
136                let at = checked.iter().position(|(k, _)| k == key)?;
137                Some(checked.remove(at).1)
138            });
139            if let Ok(mut taken) = TAKEN.lock() {
140                taken.retain(|s| s.key != key);
141                let talk = if due.verb == "reply" { due.talk.clone() } else { String::new() };
142                taken.push(Sent { key: key.to_string(), notice: due.notice.clone(), hash: rules::words_hash(&due.words), talk, taken: now, before: before.unwrap_or(true) });
143            }
144            json!({ "go": true, "words": due.words })
145        }
146        Ok(Taken::Late) => {
147            eprintln!("sioul: replies: one came to the listener too late, not sent");
148            json!({ "go": false, "said": true })
149        }
150        Ok(Taken::No) => json!({ "go": false }),
151        Err(e) => {
152            // Not written: nothing is sent.
153            eprintln!("sioul: replies: {e}");
154            json!({ "go": false })
155        }
156    }
157}
158
159/// What became of one, as Java says it, written in the private folder;
160/// one handed is looked for in its app's next notification (`heard`).
161fn result(key: &str, asked: &Value) -> Value {
162    let state = asked["state"].as_str().filter(|s| STATES.contains(s)).unwrap_or("failed");
163    let now = now_ms();
164    let done = Done { crossed: asked["crossed"] == true, held: asked["held"] == true, ..Done::of(key, state, now) };
165    if let Err(e) = rules::say(&rules::private_folder(), &done) {
166        eprintln!("sioul: replies: {e}");
167    }
168    let taken = TAKEN.lock().ok().and_then(|mut taken| {
169        let at = taken.iter().position(|s| s.key == key)?;
170        Some(taken.remove(at))
171    });
172    if state == "handed"
173        && let Some(sent) = taken
174    {
175        // Their answer to your answer comes through at once for twenty minutes (decision 18).
176        if let Err(e) = rules::note_answered(&rules::private_folder(), &sent.talk, now) {
177            eprintln!("sioul: replies: {e}");
178        }
179        if let Ok(mut handed) = HANDED.lock() {
180            handed.retain(|h| now - h.1 <= rules::SHOWN_WITHIN_MS);
181            handed.push((sent, now));
182        }
183    }
184    json!({ "said": true })
185}
186
187/// A reply handed lately looked for in its app's notification posted now
188/// (`appnotes::decide`, in the listener's process): its words there as
189/// yours, it is said "shown". Whether that was said (a step soon).
190pub(crate) fn heard(json: &str, posted: &Posted) -> bool {
191    let Ok(mut handed) = HANDED.lock() else { return false };
192    if handed.is_empty() || posted.key.is_empty() {
193        return false;
194    }
195    let now = now_ms();
196    handed.retain(|h| now - h.1 <= rules::SHOWN_WITHIN_MS);
197    let notice = rules::notice(&posted.package, &posted.key);
198    let extra = Extra::read(json);
199    let shown: Vec<String> = handed.iter().filter(|(s, _)| s.notice == notice && rules::shows(posted, &extra, &s.hash, s.taken, s.before)).map(|(s, _)| s.key.clone()).collect();
200    handed.retain(|(s, _)| !shown.contains(&s.key));
201    drop(handed);
202    for key in &shown {
203        if let Err(e) = rules::say(&rules::private_folder(), &Done::of(key, "shown", now)) {
204            eprintln!("sioul: replies: {e}");
205        }
206    }
207    !shown.is_empty()
208}
209
210/// This phone's own live lines (`replies::LIVE`), while the part "Messages
211/// from your phone" is on here; none elsewhere.
212fn own_live() -> Option<std::path::PathBuf> {
213    let here = sioul_sync::share::Here::load(&state_dir());
214    (!here.id.is_empty() && here.folder.is_some() && here.shares(phonemsgs::PART, &load_config())).then(|| rules::live_file(&phonemsgs::folder(), &here.id))
215}
216
217/// What a relayed notification offers a reply from your computers, said
218/// in this phone's live lines when it changed (`replies::offer`): written
219/// with its lines (`phonemsgs::note`). Whether a line was written.
220pub(crate) fn offered(device: &str, posted: &Posted, lines: &[Line], extra: &Extra, answers: bool, now_ms: i64) -> bool {
221    let Some(first) = lines.first().filter(|l| !l.notice.is_empty()) else { return false };
222    let live = Live { notice: first.notice.clone(), app: posted.package.clone(), talk: first.talk.clone(), reply: answers && extra.offer.reply, read: answers && extra.offer.read, at: now_ms, ..Live::default() };
223    match rules::offer(&rules::live_file(&phonemsgs::folder(), device), &live) {
224        Ok(written) => written,
225        Err(e) => {
226            eprintln!("sioul: replies: {e}");
227            false
228        }
229    }
230}
231
232/// A line relayed for your computers (`phonemsgs::note`): the replies'
233/// ledger's mark made now when there is none (`replies::mark_ledger`), so
234/// that a reply to it is never written before the mark.
235pub(crate) fn relayed(now_ms: i64) {
236    if let Err(e) = rules::mark_ledger(&rules::private_folder(), now_ms) {
237        eprintln!("sioul: replies: {e}");
238    }
239}
240
241/// A relayed notification gone from the phone, said in its live lines;
242/// nothing for one never relayed. Whether a line was written.
243fn removed(package: &str, key: &str) -> bool {
244    if package.is_empty() || key.is_empty() {
245        return false;
246    }
247    let Some(own) = own_live() else { return false };
248    match rules::went(&own, &rules::notice(package, key), now_ms()) {
249        Ok(written) => written,
250        Err(e) => {
251            eprintln!("sioul: replies: {e}");
252            false
253        }
254    }
255}
256
257// ---------------------------------------------------------------- the request, and never twice
258
259/// Whether this device is a phone whose listener sends replies; SIOUL_PHONE_MESSAGES makes a computer's step run too, to look at it.
260fn phone() -> bool {
261    cfg!(target_os = "android") || std::env::var_os("SIOUL_PHONE_MESSAGES").is_some()
262}
263
264/// This device's name, the sharing folder and its key, while the part
265/// "Messages from your phone" is on here; none otherwise.
266fn sharing() -> Option<(String, PathBuf, [u8; 32])> {
267    let (here, vault) = crate::share::vault()?;
268    let (folder, key) = vault?;
269    crate::share::shares(phonemsgs::PART).then_some((here, folder, key))
270}
271
272/// When this device last took its old requests and outcomes out (ms): once a day.
273static TRIMMED: Mutex<i64> = Mutex::new(0);
274
275/// Before each exchange (share.rs), with the part on: on a phone, its step
276/// (`phone_step`); once a day, on every device, the requests and outcomes
277/// past their week out (another device's ten days on: a computer gone for
278/// good never takes its own out), and on a phone its private files' old lines.
279pub(crate) fn before_exchange() {
280    let Some((here, folder, key)) = sharing() else { return };
281    let now = now_ms();
282    if phone() {
283        step(&here, &folder, &key, now);
284    }
285    let due = TRIMMED.lock().map(|mut last| {
286        let due = now - *last > 86_400_000;
287        if due {
288            *last = now;
289        }
290        due
291    });
292    if due.unwrap_or(false) {
293        let seal = ReplySeal::new(&key);
294        let trimmed = rules::trim_shared(&phonemsgs::folder(), &here, &seal, now).and_then(|_| if phone() { rules::trim_private(&rules::private_folder(), &seal, now) } else { Ok(()) });
295        if let Err(e) = trimmed {
296            eprintln!("sioul: replies: {e}");
297        }
298    }
299}
300
301/// The phone's step for replies, after an exchange that may have brought
302/// requests (steps.rs, as texts'): decided, claimed and handed to the
303/// listener; what the listener did said in this phone's outcomes. Whether an
304/// outcome was written (to share now).
305pub(crate) fn phone_step() -> bool {
306    if !phone() {
307        return false;
308    }
309    let Some((here, folder, key)) = sharing() else { return false };
310    step(&here, &folder, &key, now_ms())
311}
312
313/// `phone_step`'s work: the requests under every root (the records' copy,
314/// the sharing folder's small files, the copy fetched from the server),
315/// from the devices the sharing counts now (asked only when one is for
316/// this phone).
317fn step(here: &str, folder: &Path, key: &[u8; 32], now: i64) -> bool {
318    let seal = ReplySeal::new(key);
319    let root = phonemsgs::folder();
320    let roots: Vec<PathBuf> = std::iter::once(root.clone()).chain(crate::share::replies_copies()).collect();
321    let own = calls::own_file(&root, rules::OUTCOME, here);
322    let private = rules::private_folder();
323    // The ledger's mark, at every step, before any request is read (as texts do): the first reply is written after it.
324    if let Err(e) = rules::mark_ledger(&private, now) {
325        eprintln!("sioul: replies: {e}");
326    }
327    // Each file from where this phone was done with it: what it decided is never opened again.
328    let marks = rules::read_marks(&private);
329    let (asked, reads) = rules::read_asks_after(&roots, &seal, &marks);
330    let decided = asked.0.iter().any(|a| a.phone == here) && {
331        let standing = crate::texts::standing_of(folder, key, here, now);
332        decide(here, &asked, &root, &private, &seal, &standing, &own, now)
333    };
334    remember_reads(here, &private, &reads, &marks, now);
335    let settled = settle(&private, &own, now);
336    decided || settled
337}
338
339/// The marks moved past what this step is done with (`replies::moved_marks`):
340/// each line decided now, by the ledger, or another phone's.
341fn remember_reads(here: &str, private: &Path, reads: &[rules::FileRead], marks: &std::collections::BTreeMap<String, rules::ReadMark>, now: i64) {
342    let Ok(ledger) = rules::open_ledger(private, now) else { return };
343    let moved = rules::moved_marks(reads, &|key: &str, phone: &str| phone != here || ledger.has(key), marks);
344    if moved != *marks
345        && let Err(e) = rules::save_read_marks(private, &moved)
346    {
347        eprintln!("sioul: replies: {e}");
348    }
349}
350
351/// Each request for this phone decided once (`replies::verdict`), at most
352/// `replies::STEP_MAX` a step, under the ledger's lock both its processes
353/// take: every decision of the step written in the ledger together and
354/// flushed once, the claims among them before the listener can see one,
355/// then handed over in the private file it watches (`replies::add_due`);
356/// the others said. Each request is decided once; one decided and not said
357/// (its word could not be written) is said at the next step (`settle`).
358/// Whether an outcome was written.
359#[allow(clippy::too_many_arguments)]
360fn decide(here: &str, asked: &(Vec<Ask>, std::collections::BTreeSet<(String, String)>), root: &Path, private: &Path, seal: &ReplySeal, standing: &dyn Fn(&str) -> sioul_core::texts::Standing, own: &Path, now: i64) -> bool {
361    let (asks, cancelled) = asked;
362    if asks.iter().all(|a| a.phone != here) {
363        return false;
364    }
365    let lines = phonemsgs::read_file(&calls::own_file(root, phonemsgs::LOG, here));
366    // The replies its listener said were not sent: they never hold the same words back; those it said went.
367    let (unsent, went) = (rules::unsent(private), rules::went_keys(private));
368    let ledger_path = private.join(sioul_core::texts::LEDGER);
369    let decided = sioul_core::filelock::with_lock(&ledger_path, || -> Result<(Vec<rules::Due>, Vec<Outcome>), String> {
370        let mut ledger = rules::open_ledger(private, now)?;
371        // The phone's switch per app, "Answer from your computers" (Settings ▸ This phone ▸ On your computers).
372        let choices = phonemsgs::Choices::load(&phonemsgs::Choices::default_path());
373        let answers = |app: &str| choices.answers(app);
374        let (mut lines_out, mut due, mut said) = (Vec::new(), Vec::new(), Vec::new());
375        for ask in rules::in_turn(asks, here) {
376            if lines_out.len() >= rules::STEP_MAX {
377                break;
378            }
379            let judge = Judge { here, now_ms: now, ledger: &ledger, lines: &lines, standing, cancelled, answers: &answers, unsent: &unsent, went: &went };
380            let (state, reason) = match rules::verdict(ask, &judge) {
381                // Its device not known yet: judged again at the next step (its line's mark stays before it).
382                Verdict::NotMine | Verdict::Decided | Verdict::Wait => continue,
383                Verdict::Send => {
384                    // With the conversation of the line it answers: the same words held back through its newer notification too.
385                    let talk = lines.iter().find(|l| l.id == ask.line).map_or("", |l| l.talk.as_str());
386                    let claim = rules::claim_of(ask, talk, now);
387                    // Seen by this step's next requests (the guards), as the ledger will hold it.
388                    ledger.add(claim.clone());
389                    lines_out.push(claim);
390                    if let Some(line) = lines.iter().find(|l| l.id == ask.line) {
391                        due.push(rules::due_of(ask, line, now));
392                    }
393                    continue;
394                }
395                Verdict::TakenBack => ("taken-back", ""),
396                Verdict::Expired => ("expired", ""),
397                Verdict::Clocks => ("clocks", ""),
398                Verdict::Refused(why) => ("refused", why),
399            };
400            let end = rules::end_of(&ask.key, state, reason, now);
401            ledger.add(end.clone());
402            lines_out.push(end);
403            said.push(Outcome { key: ask.key.clone(), state: state.into(), at: now, reason: reason.into(), ..Outcome::default() });
404        }
405        // Every decision flushed, the claims among them, before anything is handed over or said.
406        sioul_core::texts::record_all(private, &lines_out)?;
407        Ok((due, said))
408    });
409    match decided {
410        Ok((due, said)) => {
411            if !due.is_empty() {
412                // Claimed already: not handed over (a full disk), each is said "late" in two minutes, never sent.
413                match rules::add_due(private, &due, seal, now) {
414                    Ok(()) => eprintln!("sioul: replies: {} handed to the listener", due.len()),
415                    Err(e) => eprintln!("sioul: replies: {e}"),
416                }
417            }
418            match rules::say_outcomes(own, &said) {
419                Ok(written) => written > 0,
420                Err(e) => {
421                    // Decided and in the ledger: said at the next step (`settle`).
422                    eprintln!("sioul: replies: {e}");
423                    false
424                }
425            }
426        }
427        Err(e) => {
428            // Nothing flushed: nothing handed over, nothing said; decided again at the next step.
429            eprintln!("sioul: replies: {e}");
430            false
431        }
432    }
433}
434
435/// What the listener did said in this phone's outcomes, each state once;
436/// a claim it never took within two minutes said "late", one it took with
437/// no word after "doubt" (`replies::settle`); each claim ended so written
438/// in the ledger; and a decision the ledger holds without its word (one
439/// that could not be written at its step), said now (`replies::ends_of`).
440/// Whether an outcome was written.
441fn settle(private: &Path, own: &Path, now: i64) -> bool {
442    if !private.join(sioul_core::texts::LEDGER).is_file() {
443        return false;
444    }
445    let Ok(ledger) = rules::open_ledger(private, now) else { return false };
446    let mut last: std::collections::BTreeMap<&str, &Claim> = std::collections::BTreeMap::new();
447    for claim in &ledger.claims {
448        last.insert(claim.key.as_str(), claim);
449    }
450    let claimed: Vec<(String, i64)> = last.values().filter(|c| c.state == "claimed").map(|c| (c.key.clone(), c.at)).collect();
451    let done = match rules::settle(private, &claimed, now) {
452        Ok(done) => done,
453        Err(e) => {
454            eprintln!("sioul: replies: {e}");
455            return false;
456        }
457    };
458    let mut outcomes = rules::outcomes_of(&done);
459    outcomes.extend(rules::ends_of(&ledger, now));
460    let written = rules::say_outcomes(own, &outcomes).unwrap_or_else(|e| {
461        eprintln!("sioul: replies: {e}");
462        0
463    });
464    // Each claim ended so, written together: one flush a step.
465    let ended: std::collections::BTreeSet<&str> = done.iter().filter(|d| d.state != "taken").map(|d| d.key.as_str()).collect();
466    let ends: Vec<Claim> = claimed.iter().filter(|(key, _)| ended.contains(key.as_str())).map(|(key, _)| Claim { key: key.clone(), at: now, state: "done".into(), ..Claim::default() }).collect();
467    if let Err(e) = sioul_core::texts::record_all(private, &ends) {
468        eprintln!("sioul: replies: {e}");
469    }
470    written > 0
471}
472
473/// A reply, or a Mark as read, written on this computer for a phone's
474/// notification (the Porch's line): refused here at once for what the phone
475/// would refuse anyway (no words, too long), else a request with a key of
476/// its own in this device's file, which the sharing carries, sent now on
477/// the lane of the texts to send. Its key, or why not, in words.
478pub(crate) fn ask(phone: &str, verb: &str, notice: &str, app: &str, line: &str, words: &str, again: &str) -> Result<String, String> {
479    let tr = crate::backend::tr();
480    let Some((here, _, key)) = sharing() else { return Err(tr.text("replies-refused", None)) };
481    let words = if verb == "reply" { words.trim().to_string() } else { String::new() };
482    if verb == "reply" && words.is_empty() {
483        return Err(tr.text("replies-refused-no-words", None));
484    }
485    if words.chars().count() > rules::WORDS_MAX {
486        return Err(tr.text("replies-refused-too-long", None));
487    }
488    let ask = Ask { key: sioul_core::texts::new_key(), phone: phone.to_string(), verb: verb.to_string(), notice: notice.to_string(), app: app.to_string(), line: line.to_string(), words, written: now_ms(), ahead: crate::texts::ahead_of(phone), again: again.to_string(), ..Ask::default() };
489    rules::write_ask(&phonemsgs::folder(), &here, &ask, &ReplySeal::new(&key))?;
490    crate::share::replies_now();
491    Ok(ask.key)
492}
493
494/// A request taken back while it waits for the phone (Take back): a
495/// "cancel" of its key from this device, which the phone files as taken
496/// back unless it claimed it already (its outcome then says what became of it).
497pub(crate) fn take_back(key: &str) -> Result<(), String> {
498    let Some((here, _, sharing_key)) = sharing() else { return Ok(()) };
499    let seal = ReplySeal::new(&sharing_key);
500    let root = phonemsgs::folder();
501    let (asks, _) = rules::read_asks(std::slice::from_ref(&root), &seal);
502    let Some(asked) = asks.into_iter().find(|a| a.key == key && a.device == here) else { return Ok(()) };
503    let cancel = Ask { verb: "cancel".into(), words: String::new(), written: now_ms(), ahead: crate::texts::ahead_of(&asked.phone), again: String::new(), device: String::new(), ..asked };
504    rules::write_ask(&root, &here, &cancel, &seal)?;
505    crate::share::replies_now();
506    Ok(())
507}
508
509/// Whether a conversation was answered from a computer within twenty
510/// minutes (`replies::answered_lately`): `appnotes::decide` lets its
511/// messages through at once then, but in a pause.
512pub(crate) fn answered_lately(talk: &str) -> bool {
513    rules::answered_lately(&rules::private_folder(), talk, now_ms())
514}
515
516/// A text a computer asked for, handed to Android (`texts`): an answer as
517/// a reply is, by a hash of the number (`replies::answered_number`), so
518/// that their next texts come through at once for twenty minutes too.
519pub(crate) fn texted(number: &str) {
520    if let Err(e) = rules::note_answered(&rules::private_folder(), &rules::answered_number(number), now_ms()) {
521        eprintln!("sioul: replies: {e}");
522    }
523}
524
525/// Whether one of these numbers (`phones::key`) was texted from a computer within twenty minutes.
526pub(crate) fn texted_lately(numbers: &[String]) -> bool {
527    let (folder, now) = (rules::private_folder(), now_ms());
528    numbers.iter().any(|n| rules::answered_lately(&folder, &rules::answered_number(n), now))
529}
530
531/// What this computer answered lately, for its Porch (`replies::Answered`):
532/// its replies handed or shown within twenty minutes, the numbers it texted then.
533pub(crate) fn answered_here(all: &[Line], now: &jiff::Zoned) -> rules::Answered {
534    let Some((here, _, key)) = sharing() else { return rules::Answered::default() };
535    let root = phonemsgs::folder();
536    let now_ms = now.timestamp().as_millisecond();
537    let own: Vec<Ask> = rules::read_asks(std::slice::from_ref(&root), &ReplySeal::new(&key)).0.into_iter().filter(|a| a.device == here).collect();
538    let mut answered = rules::Answered::of(&own, &rules::read_outcomes(&root), all, now_ms);
539    // The texts this computer sent, read only where a phone's line is a text.
540    if all.iter().any(|l| l.kind == "text") {
541        for number in crate::texts::texted_since(now_ms - rules::ANSWERED_MS) {
542            answered.texted(number);
543        }
544    }
545    answered
546}
547
548/// The Porch's lines' answers on a computer with the part on
549/// (`replies::answer_of`): what each phone says of its notifications, this
550/// computer's own requests and every phone's outcomes, the texts this
551/// computer wrote; a line whose Mark as read was handed is seen, here and on
552/// every device, and leaves the Porch (decision 13).
553pub(crate) fn fill(lines: &mut Vec<sioul_core::phonemsgs::PorchLine>, all: &[Line], now: &jiff::Zoned) {
554    let Some((here, _, key)) = sharing() else { return };
555    let root = phonemsgs::folder();
556    let now_ms = now.timestamp().as_millisecond();
557    let live = rules::read_live(&root, now_ms);
558    let seal = ReplySeal::new(&key);
559    let (asks, cancels) = rules::read_asks(std::slice::from_ref(&root), &seal);
560    let own: Vec<Ask> = asks.into_iter().filter(|a| a.device == here).collect();
561    // What this computer took back (Take back): said at once.
562    let taken_back: std::collections::BTreeSet<String> = cancels.into_iter().filter(|(device, _)| *device == here).map(|(_, key)| key).collect();
563    let outcomes = rules::read_outcomes(&root);
564    let phones = crate::texts::phones();
565    let shared = |device: &str| phones.iter().find(|p| p.id == device).filter(|p| p.exported > 0).map(|p| p.exported * 1000);
566    let time_of = |ms: i64| sioul_core::texts::time_of_day(ms, now);
567    let sent = |phone: &str, dial: &str, since: i64| crate::texts::sent_to(phone, dial, since);
568    let texts_from = |phone: &str| crate::texts::sends_texts(phone);
569    let offer = rules::Offer { live: &live, asks: &own, outcomes: &outcomes, tr: crate::backend::tr(), taken_back: &taken_back, lines: all, texts_from: &texts_from, shared: &shared, time_of: &time_of, sent_texts: &sent };
570    let by_id: std::collections::BTreeMap<&str, &Line> = all.iter().map(|l| (l.id.as_str(), l)).collect();
571    let mut seen: Vec<String> = Vec::new();
572    for line in lines.iter_mut() {
573        let group: Vec<&Line> = line.ids.iter().filter_map(|id| by_id.get(id.as_str()).copied()).collect();
574        line.answer = rules::answer_of(line, &group, &offer);
575        if line.answer.as_ref().is_some_and(|a| rules::read_handed(&own, &outcomes, &a.phone, &a.notice)) {
576            seen.extend(line.ids.iter().cloned());
577        }
578    }
579    if !seen.is_empty() {
580        if let Err(e) = calls::mark_seen(&root, &here, &seen, now_ms) {
581            eprintln!("sioul: replies: {e}");
582        }
583        lines.retain(|l| !l.ids.iter().any(|id| seen.contains(id)));
584    }
585}
586
587/// `listener`, for Java (android/main.cpp, AppNotes.nativeReply); a panic
588/// answers "null", and nothing is sent. Given back to `sioul_string_free`.
589///
590/// # Safety
591/// `verb` and `json` are null, or zero-terminated texts valid for the call.
592// SAFETY: no other symbol of the program has this name.
593#[unsafe(no_mangle)]
594pub unsafe extern "C" fn sioul_replies_listener(verb: *const c_char, json: *const c_char) -> *mut c_char {
595    // SAFETY: each null or a C string valid for the call, as this function's contract says.
596    let (verb, json) = unsafe { (crate::alarms::key_of(verb), crate::alarms::key_of(json)) };
597    let answer = std::panic::catch_unwind(|| listener(&verb, &json).to_string()).unwrap_or_else(|_| "null".to_string());
598    crate::alarms::handed(answer)
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    /// A relayed notification's offer goes into this phone's live lines
606    /// (in the test's own state folder) once per change; a line from an older
607    /// phone, without its notification's name, says nothing.
608    #[test]
609    fn a_relayed_notification_says_what_it_offers() {
610        let device = format!("phone-{}", std::process::id());
611        let posted = Posted { key: "0|org.thoughtcrime.securesms|42|null|10123".into(), package: "org.thoughtcrime.securesms".into(), ..Posted::default() };
612        let line = Line { at: 1, id: "1-a".into(), notice: rules::notice(&posted.package, &posted.key), talk: "fedcba9876543210".into(), ..Line::default() };
613        let extra = Extra { offer: phonemsgs::Offer { reply: true, read: false }, ..Extra::default() };
614        let now = now_ms();
615        assert!(offered(&device, &posted, std::slice::from_ref(&line), &extra, true, now));
616        assert!(!offered(&device, &posted, std::slice::from_ref(&line), &extra, true, now + 1_000), "the same offer");
617        assert!(!offered(&device, &posted, &[Line { notice: String::new(), ..line.clone() }], &extra, true, now + 2_000), "an older line, no name");
618        assert!(!offered(&device, &posted, &[], &extra, true, now + 3_000));
619        let live = rules::read_live(&phonemsgs::folder(), now + 4_000);
620        let said = live.get(&(device.clone(), line.notice.clone())).unwrap();
621        assert_eq!((said.reply, said.read, said.talk.as_str(), said.app.as_str()), (true, false, "fedcba9876543210", "org.thoughtcrime.securesms"));
622        // "Answer from your computers" switched off for the app: offered no more.
623        assert!(offered(&device, &posted, std::slice::from_ref(&line), &extra, false, now + 5_000));
624        assert!(!rules::read_live(&phonemsgs::folder(), now + 6_000).get(&(device.clone(), line.notice.clone())).unwrap().reply);
625        let _ = std::fs::remove_file(rules::live_file(&phonemsgs::folder(), &device));
626    }
627
628    /// A phone's step, on stand-in folders: a request decided once, claimed
629    /// before the listener sees it, handed over with its words in the sealed
630    /// due file; the listener's word copied into the phone's outcomes, the
631    /// claim ended; one taken back, one from a device the sharing no longer
632    /// counts, one the listener never took (late): each said once, without
633    /// the words.
634    #[test]
635    fn the_phone_decides_each_request_once_and_says_what_became_of_it() {
636        let base = std::env::temp_dir().join(format!("sioul-app-replies-step-{}", std::process::id()));
637        let _ = std::fs::remove_dir_all(&base);
638        let (root, private) = (base.join("phone-messages"), base.join("private"));
639        let seal = ReplySeal::new(&[9u8; 32]);
640        let now = now_ms();
641        let line = Line { at: now - 300_000, id: format!("{}-aa", now - 300_000), app: "org.thoughtcrime.securesms".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Tu passes ?".into(), shows: now - 300_000, ..Line::default() };
642        phonemsgs::carry_into(&calls::own_file(&root, phonemsgs::LOG, "phone"), std::slice::from_ref(&line), now).unwrap();
643        // The listener relayed the line a minute before: the ledger's mark made then.
644        rules::mark_ledger(&private, now - 60_000).unwrap();
645        let ask = |key: &str, device: &str| (device.to_string(), Ask { key: key.into(), phone: "phone".into(), verb: "reply".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), words: format!("Réponse {key}."), written: now, ..Ask::default() });
646        for (device, a) in [ask("k1", "desk"), ask("k2", "desk"), ask("k3", "laptop"), ask("k4", "desk")] {
647            rules::write_ask(&root, &device, &a, &seal).unwrap();
648        }
649        rules::write_ask(&root, "desk", &Ask { verb: "cancel".into(), words: String::new(), ..ask("k2", "desk").1 }, &seal).unwrap();
650        let own = calls::own_file(&root, rules::OUTCOME, "phone");
651        let counted = |device: &str| if device == "desk" { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone };
652        let roots = vec![root.clone()];
653        let asked = || rules::read_asks(&roots, &seal);
654        assert!(decide("phone", &asked(), &root, &private, &seal, &counted, &own, now), "taken back and not counted, said");
655        let due = rules::read_due(&private, &seal);
656        assert_eq!(due.iter().map(|d| (d.key.as_str(), d.words.as_str(), d.talk.as_str())).collect::<Vec<_>>(), [("k1", "Réponse k1.", "fedcba9876543210"), ("k4", "Réponse k4.", "fedcba9876543210")]);
657        // Each computer's requests in turn (`replies::in_turn`): by key here.
658        let states = |o: &[Outcome]| {
659            let mut said = o.iter().map(|o| (o.key.clone(), o.state.clone(), o.reason.clone())).collect::<Vec<_>>();
660            said.sort();
661            said
662        };
663        let said = rules::read_outcomes(&root);
664        assert_eq!(states(&said), [("k2".to_string(), "taken-back".to_string(), String::new()), ("k3".into(), "refused".into(), "not-counted".into())]);
665        // Read again: decided once.
666        assert!(!decide("phone", &asked(), &root, &private, &seal, &counted, &own, now + 1_000));
667        assert_eq!(rules::read_due(&private, &seal).len(), 2);
668        // The listener takes k1 and hands it; k4 it never takes.
669        assert!(matches!(rules::take(&private, "k1", &seal, now + 2_000).unwrap(), rules::Taken::Go(_)));
670        rules::say(&private, &Done::of("k1", "handed", now + 3_000)).unwrap();
671        assert!(settle(&private, &own, now + 4_000));
672        assert!(rules::read_outcomes(&root).iter().any(|o| o.key == "k1" && o.state == "handed"));
673        assert!(settle(&private, &own, now + 3 * 60_000), "k4, never taken: late");
674        let all = rules::read_outcomes(&root);
675        assert!(all.iter().any(|o| o.key == "k4" && o.state == "late"), "{all:?}");
676        assert!(!settle(&private, &own, now + 4 * 60_000), "each once; the claims ended");
677        let ledger = rules::open_ledger(&private, now).unwrap();
678        assert_eq!((ledger.state("k1"), ledger.state("k4"), ledger.state("k3")), (Some("done"), Some("done"), Some("refused:not-counted")));
679        let text = std::fs::read_to_string(&own).unwrap();
680        assert!(!text.contains("Réponse") && !text.contains("Alice"), "no words, no names");
681        // A decision whose word was lost (its file gone): said again at the next step, once.
682        std::fs::remove_file(&own).unwrap();
683        assert!(settle(&private, &own, now + 5 * 60_000));
684        let again = rules::read_outcomes(&root);
685        assert!(again.iter().any(|o| o.key == "k3" && o.state == "refused" && o.reason == "not-counted"), "{again:?}");
686        assert!(!settle(&private, &own, now + 6 * 60_000), "once");
687        let _ = std::fs::remove_dir_all(&base);
688    }
689
690    /// The first reply after the install, from an empty private folder: the
691    /// listener relays the line (the ledger's mark made then, the only
692    /// thing in the folder), the reply is written a minute and a half later,
693    /// the phone's first step after it sends it: never "before-mark". Its
694    /// notification gone when the listener came, the same words written again
695    /// go too: the first never went.
696    #[test]
697    fn the_first_reply_after_install_goes_and_one_gone_can_go_again() {
698        let base = std::env::temp_dir().join(format!("sioul-app-replies-first-{}", std::process::id()));
699        let _ = std::fs::remove_dir_all(&base);
700        let (root, private) = (base.join("phone-messages"), base.join("private"));
701        let seal = ReplySeal::new(&[9u8; 32]);
702        let t0 = now_ms() - 10 * 60_000;
703        let line = Line { at: t0 - 60_000, id: format!("{}-aa", t0 - 60_000), app: "org.thoughtcrime.securesms".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Tu passes ?".into(), shows: t0, ..Line::default() };
704        assert!(!private.exists(), "an empty private folder");
705        rules::mark_ledger(&private, t0).unwrap();
706        phonemsgs::carry_into(&calls::own_file(&root, phonemsgs::LOG, "phone"), std::slice::from_ref(&line), t0).unwrap();
707        let ask = |key: &str, at: i64| Ask { key: key.into(), phone: "phone".into(), verb: "reply".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), words: "J'arrive.".into(), written: at, ..Ask::default() };
708        rules::write_ask(&root, "desk", &ask("k1", t0 + 90_000), &seal).unwrap();
709        let own = calls::own_file(&root, rules::OUTCOME, "phone");
710        let counted = |device: &str| if device == "desk" { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone };
711        let roots = vec![root.clone()];
712        // A phone's step, as `step` runs it on the phone's own folders.
713        let step = |at: i64| {
714            rules::mark_ledger(&private, at).unwrap();
715            let decided = decide("phone", &rules::read_asks(&roots, &seal), &root, &private, &seal, &counted, &own, at);
716            settle(&private, &own, at) || decided
717        };
718        step(t0 + 180_000);
719        assert_eq!(rules::read_due(&private, &seal).iter().map(|d| d.key.as_str()).collect::<Vec<_>>(), ["k1"], "sent: {:?}", rules::read_outcomes(&root));
720        assert!(matches!(rules::take(&private, "k1", &seal, t0 + 200_000).unwrap(), rules::Taken::Go(_)));
721        rules::say(&private, &Done::of("k1", "gone", t0 + 201_000)).unwrap();
722        step(t0 + 240_000);
723        assert!(rules::read_outcomes(&root).iter().any(|o| o.key == "k1" && o.state == "gone"));
724        // Her next message brings the notification back; the same words again: they go.
725        rules::write_ask(&root, "desk", &ask("k2", t0 + 300_000), &seal).unwrap();
726        step(t0 + 360_000);
727        let said = rules::read_outcomes(&root);
728        assert!(!said.iter().any(|o| o.key == "k2"), "{said:?}");
729        assert!(rules::read_due(&private, &seal).iter().any(|d| d.key == "k2"));
730        let _ = std::fs::remove_dir_all(&base);
731    }
732
733    fn copy_all(from: &Path, to: &Path) {
734        std::fs::create_dir_all(to).unwrap();
735        for entry in std::fs::read_dir(from).unwrap().flatten() {
736            let path = entry.path();
737            if path.is_dir() {
738                copy_all(&path, &to.join(entry.file_name()));
739            } else {
740                std::fs::copy(&path, to.join(entry.file_name())).unwrap();
741            }
742        }
743    }
744
745    /// A dose's step (`step`'s work, which a dose's alarm runs before its
746    /// pull) with a runaway computer's 10,000 requests decided already and
747    /// 150 claims the listener never took, settled "late" now: a measure,
748    /// printed; run with `cargo test --release -p sioul-app -- --ignored
749    /// a_dose_step --nocapture`, empty folders of its own.
750    #[test]
751    #[ignore]
752    fn a_dose_step_with_a_runaway_backlog_stays_within_tens_of_milliseconds() {
753        let base = std::env::temp_dir().join(format!("sioul-app-replies-dose-{}", std::process::id()));
754        let _ = std::fs::remove_dir_all(&base);
755        let template = base.join("template");
756        let (root, private) = (template.join("phone-messages"), template.join("private"));
757        let seal = ReplySeal::new(&[9u8; 32]);
758        let now = now_ms();
759        rules::mark_ledger(&private, now - 86_400_000).unwrap();
760        let line = Line { at: now - 600_000, id: format!("{}-aa", now - 600_000), app: "org.thoughtcrime.securesms".into(), kind: "chat".into(), talk: "fedcba9876543210".into(), notice: "0123456789abcdef".into(), name: "Alice".into(), key: "+33199001234".into(), who: "neutral".into(), text: "Tu passes ?".into(), shows: now - 600_000, ..Line::default() };
761        phonemsgs::carry_into(&calls::own_file(&root, phonemsgs::LOG, "phone"), std::slice::from_ref(&line), now).unwrap();
762        // The runaway computer's 10,000 Marks as read, sealed as `write_ask` writes them, all decided already.
763        let mut file = String::new();
764        let mut ends = Vec::new();
765        for i in 0..10_000 {
766            let key = format!("{i:032x}");
767            let ask = Ask { key: key.clone(), phone: "phone".into(), verb: "read".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), written: now - 20 * 60_000, writer: "runaway".into(), ..Ask::default() };
768            file.push_str(&sioul_core::texts::Sealer::seal(&seal, &rules::padded_ask(&ask)));
769            file.push('\n');
770            ends.push(rules::end_of(&key, "expired", "", now - 10 * 60_000));
771        }
772        std::fs::create_dir_all(root.join(rules::ASK)).unwrap();
773        std::fs::write(calls::own_file(&root, rules::ASK, "runaway"), file).unwrap();
774        // Their words said already, as their step said them.
775        let said: Vec<Outcome> = ends.iter().map(|c| Outcome { key: c.key.clone(), state: "expired".into(), at: c.at, ..Outcome::default() }).collect();
776        rules::say_outcomes(&calls::own_file(&root, rules::OUTCOME, "phone"), &said).unwrap();
777        // And 150 claims the listener never took, three minutes ago.
778        ends.extend((0..150).map(|i| Claim { key: format!("c{i:031x}"), at: now - 3 * 60_000, state: "claimed".into(), to: line.notice.clone(), ..Claim::default() }));
779        sioul_core::texts::record_all(&private, &ends).unwrap();
780        let counted = |device: &str| if device == "runaway" { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone };
781        let mut times = Vec::new();
782        for run in 0..7 {
783            let at = base.join(format!("run-{run}"));
784            copy_all(&template, &at);
785            let (root, private) = (at.join("phone-messages"), at.join("private"));
786            let own = calls::own_file(&root, rules::OUTCOME, "phone");
787            // The step that decided them moved the marks past them (by their paths, this copy's).
788            let (_, reads) = rules::read_asks_after(std::slice::from_ref(&root), &seal, &std::collections::BTreeMap::new());
789            remember_reads("phone", &private, &reads, &std::collections::BTreeMap::new(), now);
790            let started = std::time::Instant::now();
791            rules::mark_ledger(&private, now).unwrap();
792            let marks = rules::read_marks(&private);
793            let (asked, reads) = rules::read_asks_after(std::slice::from_ref(&root), &seal, &marks);
794            let read = started.elapsed();
795            let decided = asked.0.iter().any(|a| a.phone == "phone") && decide("phone", &asked, &root, &private, &seal, &counted, &own, now);
796            remember_reads("phone", &private, &reads, &marks, now);
797            let settled = settle(&private, &own, now);
798            let took = started.elapsed();
799            assert!(!decided && settled, "nothing new decided; the 150 said late");
800            assert!(asked.0.is_empty(), "the 10,000 decided never opened again");
801            times.push((took.as_secs_f64() * 1000.0, read.as_secs_f64() * 1000.0));
802            let late = rules::read_outcomes(&root).iter().filter(|o| o.state == "late").count();
803            assert_eq!(late, 150);
804        }
805        times.sort_by(|a, b| a.0.total_cmp(&b.0));
806        let (median, max) = (times[times.len() / 2], times[times.len() - 1]);
807        println!("a dose's step, 10,000 requests decided and 150 claims never taken: {:.1} ms median (reading the requests {:.1} ms), {:.1} ms at most", median.0, median.1, max.0);
808        assert!(median.0 < 1_000.0, "{times:?}");
809        let _ = std::fs::remove_dir_all(&base);
810    }
811}