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}