Skip to main content

sioul_app/
devices.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! This device's sessions, as your other devices learn them through the
5//! sharing folder (docs/database.md, "Devices"; `sioul_sync::devices`): its
6//! start, its clean close, and between them `working`, up.
7//!
8//! - On a computer, a session is the window's life: up when Sioul starts,
9//!   down as the last step of a clean close (the window closed, or the stop
10//!   signal: cpp/signals.cpp quits Qt), after the final export (`share::closing`).
11//! - On a phone, a session is the time on the screen: up when Sioul comes
12//!   back, down when it goes to the background, after its export (Android may
13//!   end a process in the background without a word, so it goes down then).
14//! - A reminder handled while no session is open here (a dose's alarm,
15//!   "Taken" pressed on its notification) is a session of its own: up, what
16//!   it records, its export, down (`receiver`). Only when the last one ends,
17//!   the window's or a receiver's, does it go down.
18//! - Each exchange says what it read and wrote (`exported`), never touching
19//!   the session: a phone's background service exporting in its own process
20//!   never undoes the window's.
21//!
22//! A crash leaves it up: your other devices then say they cannot tell what
23//! was marked here, until it starts again or you say it is off.
24
25use sioul_sync::devices::{COMPUTER, Entry, PHONE};
26use std::sync::Mutex;
27use std::sync::atomic::{AtomicI64, Ordering};
28
29/// The sessions open in this process: the window's, and receivers at work.
30struct Sessions {
31    window: bool,
32    receivers: u32,
33}
34
35static SESSIONS: Mutex<Sessions> = Mutex::new(Sessions { window: false, receivers: 0 });
36
37/// When this process last recorded something of the doses (a mark, a record,
38/// a reminder), and when its last export read the files (milliseconds, this
39/// device's clock). A session goes down only once an export read the files
40/// after the last of them: else an answer captured here may not have gone
41/// out, and saying "closed" would make the others sure of what they lack.
42static RECORDED: AtomicI64 = AtomicI64::new(0);
43static EXPORTED: AtomicI64 = AtomicI64::new(0);
44
45/// Something of the doses recorded here (`health::change`, `change_records`):
46/// owed to the others until an export reads it.
47pub(crate) fn recorded() {
48    RECORDED.fetch_max(jiff::Timestamp::now().as_millisecond(), Ordering::SeqCst);
49}
50
51/// Whether everything recorded here went out with an export since.
52fn all_out() -> bool {
53    went_out(RECORDED.load(Ordering::SeqCst), EXPORTED.load(Ordering::SeqCst))
54}
55
56/// Whether what was recorded (`RECORDED`) went out with an export since
57/// (`EXPORTED`): both 0 in a process just started, which knows nothing of an
58/// earlier one's.
59fn went_out(recorded: i64, exported: i64) -> bool {
60    recorded < exported || recorded == 0
61}
62
63fn sessions() -> std::sync::MutexGuard<'static, Sessions> {
64    SESSIONS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
65}
66
67fn now() -> i64 {
68    jiff::Timestamp::now().as_second()
69}
70
71/// This device's name, to say where: a computer's host name; a phone's
72/// model (its host name is "localhost"), said "the phone" by the others.
73pub(crate) fn name() -> String {
74    static NAME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
75    NAME.get_or_init(|| {
76        if cfg!(target_os = "android") {
77            let model = std::process::Command::new("/system/bin/getprop").arg("ro.product.model").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
78            model.filter(|m| !m.is_empty()).unwrap_or_else(|| PHONE.to_string())
79        } else {
80            sioul_sync::lease::host_name()
81        }
82    })
83    .clone()
84}
85
86/// A phone, or a computer.
87pub(crate) fn kind() -> &'static str {
88    if cfg!(target_os = "android") { PHONE } else { COMPUTER }
89}
90
91/// This device's entry changed by `change`, its identity set: written here,
92/// and into the folder when sharing is on. Never fails loudly: the doses of
93/// the others say what they cannot know when it is not written.
94fn write(change: impl FnOnce(&mut Entry)) {
95    let Some((id, vault)) = crate::share::vault() else { return };
96    let path = sioul_sync::devices::own_path(&sioul_core::config::state_dir());
97    // Its doses said known only while their part travels: held under a newer
98    // format (`share::holds`), they are not, whatever this process recorded.
99    let doses = doses_said(crate::share::shares("health"), crate::share::holds_doses());
100    // A phone's notification access, as Android says it now: what it may hold of other apps.
101    let notifications = crate::appnotes::access_here();
102    let written = sioul_sync::devices::change(&path, vault.as_ref().map(|(folder, key)| (folder.as_path(), key)), |entry| {
103        entry.id = id;
104        entry.name = name();
105        entry.kind = kind().into();
106        // The build that writes it (docs/database.md, "Devices"): which code each device runs.
107        entry.version = sioul_core::build::VERSION.into();
108        entry.commit = sioul_core::build::COMMIT.into();
109        entry.format = sioul_sync::share::FORMAT;
110        entry.doses = doses;
111        if notifications.is_some() {
112            entry.notifications = notifications;
113        }
114        change(entry);
115    });
116    if let Err(e) = written {
117        eprintln!("sioul: devices: {e}");
118    }
119}
120
121/// The window opened, or a phone's Sioul back on the screen: up. Found up
122/// already (the last session ended without closing: a crash), what it left
123/// is owed an export before the next close.
124pub(crate) fn window_opened() {
125    let mut open = sessions();
126    open.window = true;
127    raise();
128}
129
130/// Up, said; a session found up already owes an export.
131fn raise() {
132    write(|entry| {
133        if entry.working {
134            recorded();
135        }
136        entry.start(now());
137    });
138}
139
140/// Down, said, the last step of a session, once all it recorded went out;
141/// else it stays up, and the others say what they cannot know until the
142/// next session's export.
143fn lower() -> bool {
144    if !may_lower(all_out(), crate::share::holds_doses()) {
145        eprintln!("sioul: devices: what was recorded here has not gone out; this device stays up");
146        return false;
147    }
148    write(|entry| entry.close(now()));
149    true
150}
151
152/// Whether this device's entry may say it shares its doses: it shares the
153/// health part, and that part is not held here under a newer format (its
154/// answers then wait unsent, and the others must not count them as known).
155fn doses_said(shares: bool, held: bool) -> bool {
156    shares && !held
157}
158
159/// Whether a session may go down (closed: everything it captured went out):
160/// all it recorded went out, and the doses' part is not held here, which
161/// this process's own counts cannot tell after a restart.
162fn may_lower(all_out: bool, held: bool) -> bool {
163    all_out && !held
164}
165
166/// The window's session ended, its final export done (`share::closing`):
167/// down, unless a receiver is still at work, or `ended` says it is not over
168/// after all (a phone's Sioul brought back meanwhile).
169pub(crate) fn window_closed(ended: impl Fn() -> bool) {
170    let mut open = sessions();
171    if !ended() {
172        return;
173    }
174    open.window = false;
175    if open.receivers == 0 {
176        lower();
177    }
178}
179
180/// `work` (what a reminder records, then its export) as a session of its own
181/// when none is open here: up first, so that a process ended in its midst
182/// leaves the others in doubt rather than sure; down after, then the sync app
183/// asked to carry it (`share::nudge_carriers`): the lowered entry goes up,
184/// not the raised one.
185pub(crate) fn receiver<T>(work: impl FnOnce() -> T) -> T {
186    {
187        let mut open = sessions();
188        if !open.window && open.receivers == 0 {
189            raise();
190        }
191        open.receivers += 1;
192    }
193    let out = work();
194    let lowered = {
195        let mut open = sessions();
196        open.receivers = open.receivers.saturating_sub(1);
197        !open.window && open.receivers == 0 && lower()
198    };
199    if lowered {
200        crate::share::nudge_carriers();
201    }
202    out
203}
204
205/// Whether a reminder's own session is at work in this process (no window open):
206/// the sync app is then asked once it is down, not by each exchange inside it.
207pub(crate) fn in_receiver() -> bool {
208    let open = sessions();
209    !open.window && open.receivers > 0
210}
211
212/// An exchange done: what it read and wrote, said; the session as it is.
213/// The first export of a session is carried at once (a phone's sync app
214/// asked to look): the others see this device in use with fresh news, not
215/// with what it last shared before it was put away.
216pub(crate) fn exported(outcome: &sioul_sync::share::Outcome) {
217    if outcome.looked <= 0 {
218        return;
219    }
220    EXPORTED.fetch_max(outcome.looked, Ordering::SeqCst);
221    let first = std::sync::atomic::AtomicBool::new(false);
222    write(|entry| {
223        first.store(entry.working && entry.exported < entry.started, std::sync::atomic::Ordering::Relaxed);
224        entry.exported(outcome.looked, outcome.wrote, now());
225    });
226    if first.load(std::sync::atomic::Ordering::Relaxed) && !in_receiver() {
227        crate::share::nudge_carriers();
228    }
229}
230
231/// Sharing stopped here: down, and said to count no more.
232pub(crate) fn left() {
233    write(|entry| {
234        entry.close(now());
235        entry.left = true;
236    });
237}
238
239/// Whether another device says it is in use, with an export dated lately
240/// (`FRESH` and `SKEW`): for the rhythm of a phone's background service only;
241/// the doses' knowing is `health::know`'s.
242pub(crate) fn others_in_use() -> bool {
243    let Some((id, Some((folder, key)))) = crate::share::vault() else { return false };
244    let now = now();
245    sioul_sync::devices::all(&folder, &key).0.iter().any(|e| e.id != id && e.working && !e.left && now - e.exported <= sioul_core::health::FRESH + sioul_core::health::SKEW)
246}
247
248/// A device's kind ("phone", "computer") and name, from its entry in the
249/// folder; none when it has none (an older Sioul, or not arrived yet).
250pub(crate) fn kind_and_name(id: &str) -> Option<(String, String)> {
251    let (_, Some((folder, key))) = crate::share::vault()? else { return None };
252    sioul_sync::devices::all(&folder, &key).0.into_iter().find(|e| e.id == id).map(|e| (e.kind, e.name))
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    /// A process started anew (an app restart; on Android, the background
260    /// process killed and started again) knows nothing of what an earlier one
261    /// recorded: its counts say all went out. While the doses' part is held
262    /// under a newer format (`share::holds`, read from the disk), its entry
263    /// still says its doses are not known, and its session never goes down as
264    /// if they were: the others doubt, never take a dose as not taken
265    /// (docs/health.md, "A newer Sioul's doses").
266    #[test]
267    fn a_held_doses_part_claims_no_knowledge_after_a_restart() {
268        // The statics as a new process has them: nothing recorded, nothing exported.
269        let (recorded, exported) = (0, 0);
270        assert!(went_out(recorded, exported), "a new process: all went out, by its own counts");
271        assert!(!may_lower(went_out(recorded, exported), true), "held: never down as if all went out");
272        assert!(!doses_said(true, true), "held: its doses said not shared");
273        assert!(may_lower(went_out(recorded, exported), false) && doses_said(true, false), "not held: as before");
274        assert!(!may_lower(went_out(5, 3), false), "an answer recorded and not exported keeps it up, as before");
275    }
276}