Skip to main content

sioul_app/
timenote.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The time running, in the system's notifications (docs/tasks.md): while a
5//! focus session runs, one notification says its task and since when, with
6//! Pause (Go on, while paused) and Stop, which do what the focus window's own
7//! buttons do. It follows the session from wherever it changes: the window,
8//! the notification itself, another device (the sharing brings
9//! `time/running.toml`), the command line. On a computer, the desktop's
10//! (`sioul_sync::notify::ongoing`), while Sioul runs. On a phone, Android's
11//! own, with a chronometer (android/main.cpp, TimeNote.java): it stays while
12//! Sioul is away, and its buttons reach here without the window
13//! (`sioul_time_action`, docs/android.md).
14
15use crate::backend::{QtThread, Shared, load_config, tr};
16use jiff::Timestamp;
17use jiff::tz::TimeZone;
18use serde::Serialize;
19use sioul_core::projects::{Project, ProjectStore};
20use sioul_core::i18n::Translator;
21use sioul_core::stopped::Stopped;
22use sioul_core::tasks::{self, Task};
23use sioul_core::timelog::{self, Running};
24use std::ffi::c_char;
25use std::path::Path;
26use std::sync::{Arc, Mutex, PoisonError};
27
28/// What the notification says while a session runs.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30struct Note {
31    /// The task's title, else its project's, else "Focus".
32    title: String,
33    /// "Since 14:02, 25 min chosen"; paused, "Paused, 12 min so far".
34    text: String,
35    paused: bool,
36    /// Where Android's chronometer counts from, Unix milliseconds: the start,
37    /// the time paused left out; 0 while paused, the chronometer stopped.
38    since: i64,
39    /// The first button's word: "Pause", or "Go on" while paused.
40    pause: String,
41    stop: String,
42    /// Android's channel for it, named in your language.
43    channel: String,
44}
45
46/// The notification for the session running, if one is: `title` names its
47/// task, `zone` tells the time it started.
48fn note_of(running: Option<&Running>, title: &str, now: i64, tr: &Translator, zone: &TimeZone) -> Option<Note> {
49    let running = running?;
50    let paused = running.paused_at.is_some();
51    let mut args = sioul_core::i18n::args();
52    let text = if paused {
53        args.set("minutes", running.elapsed(now) / 60);
54        tr.text("focus-notification-paused", Some(&args))
55    } else {
56        args.set("time", Timestamp::from_second(running.start).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default());
57        if running.planned == 0 {
58            tr.text("focus-notification-open", Some(&args))
59        } else {
60            args.set("minutes", running.planned);
61            tr.text("focus-notification-planned", Some(&args))
62        }
63    };
64    Some(Note {
65        title: if title.trim().is_empty() { tr.text("focus-title", None) } else { title.to_string() },
66        text,
67        paused,
68        since: if paused { 0 } else { (running.start + running.paused) * 1000 },
69        pause: tr.text(if paused { "focus-resume" } else { "focus-pause" }, None),
70        stop: tr.text("focus-stop", None),
71        channel: tr.text("focus-notification-channel", None),
72    })
73}
74
75/// The task's title, else its first project's; "" when neither is known here
76/// (a task not synced to this device yet).
77fn title_of(uid: &str, tasks: &[Task], projects: &[Project]) -> String {
78    let Some(task) = tasks.iter().find(|t| t.uid == uid) else { return String::new() };
79    if !task.title.trim().is_empty() {
80        return task.title.clone();
81    }
82    task.projects.iter().find_map(|id| projects.iter().find(|c| c.id == *id)).map(|c| c.title.clone()).unwrap_or_default()
83}
84
85/// What this process shows.
86struct Shown {
87    /// The note put up last; None until one was, in this process: a phone's
88    /// may be left from an earlier one, the session over since.
89    note: Option<Option<Note>>,
90    /// The task named last, and its title, for a moment without the tasks read.
91    named: (String, String),
92    /// Sioul quits: nothing more is shown.
93    over: bool,
94    /// The desktop's notification, while one shows.
95    #[cfg(not(target_os = "android"))]
96    ongoing: Option<sioul_sync::notify::Ongoing>,
97}
98
99static SHOWN: Mutex<Shown> = Mutex::new(Shown {
100    note: None,
101    named: (String::new(), String::new()),
102    over: false,
103    #[cfg(not(target_os = "android"))]
104    ongoing: None,
105});
106
107/// Sioul's window, when it runs in this process: a phone's buttons then act
108/// as its own, and it follows.
109static WINDOW: Mutex<Option<(QtThread, Arc<Shared>)>> = Mutex::new(None);
110
111/// Buttons pressed one at a time: two in a row act in their order.
112static ACTING: Mutex<()> = Mutex::new(());
113
114/// The notification made to follow the session as it is now: put up, changed
115/// or taken away. After each reading of the tasks, and at the window's minute
116/// (what changed elsewhere: another device, the command line).
117pub(crate) fn follow(qt: &QtThread, shared: &Arc<Shared>) {
118    if let Ok(mut window) = WINDOW.lock()
119        && window.is_none()
120    {
121        *window = Some((qt.clone(), Arc::clone(shared)));
122    }
123    post(Some((qt, shared)));
124}
125
126/// The note made again from the session on disk, and put up if it changed;
127/// one at a time, so that the one put up last is the latest. Returns it.
128fn post(window: Option<(&QtThread, &Arc<Shared>)>) -> Option<Note> {
129    let mut shown = SHOWN.lock().unwrap_or_else(PoisonError::into_inner);
130    if shown.over {
131        return None;
132    }
133    let running = timelog::running();
134    let title = running.as_ref().map(|r| title_now(&mut shown.named, &r.task, window.map(|(_, shared)| shared))).unwrap_or_default();
135    let note = note_of(running.as_ref(), &title, Timestamp::now().as_second(), tr(), &TimeZone::system());
136    // At a time the matrix of what reaches you says "Not at all" for it (as usual, never), the
137    // note is taken away until that time ends; the session runs on.
138    let note = note.filter(|_| crate::hours::comes(sioul_core::attention::Kind::Time));
139    if shown.note.as_ref() != Some(&note) {
140        put(&mut shown, note.as_ref(), window);
141        shown.note = Some(note.clone());
142        // Do-not-disturb while you focus follows the session at once (docs/do-not-disturb.md).
143        std::thread::spawn(crate::everywhere::apply);
144    }
145    note
146}
147
148/// The title of the task `uid`: from the tasks the window read last; without
149/// them (a phone's button, the window not running), from the task lists
150/// themselves, once per task.
151fn title_now(named: &mut (String, String), uid: &str, shared: Option<&Arc<Shared>>) -> String {
152    let read = shared.and_then(|s| s.loaded.lock().ok().and_then(|l| l.clone()));
153    let title = match read {
154        Some(loaded) => title_of(uid, &loaded.tasks, &loaded.projects),
155        None if named.0 == uid => return named.1.clone(),
156        None => {
157            let projects = load_config().notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|store| store.projects).unwrap_or_default();
158            title_of(uid, &tasks::all(&TimeZone::system()), &projects)
159        }
160    };
161    *named = (uid.to_string(), title.clone());
162    title
163}
164
165/// The note put up, changed, or taken away: the desktop's notification, made
166/// with the window, whose buttons it reaches.
167#[cfg(not(target_os = "android"))]
168fn put(shown: &mut Shown, note: Option<&Note>, window: Option<(&QtThread, &Arc<Shared>)>) {
169    let Some(note) = note else {
170        // Dropped, it is taken away.
171        shown.ongoing = None;
172        return;
173    };
174    let buttons = vec![
175        (if note.paused { "resume" } else { "pause" }.to_string(), note.pause.clone()),
176        ("stop".to_string(), note.stop.clone()),
177        ("default".to_string(), tr().text("reminder-open", None)),
178    ];
179    if let Some(ongoing) = &shown.ongoing {
180        ongoing.update(&note.title, &note.text, buttons);
181    } else if let Some((qt, shared)) = window {
182        let (qt, shared) = (qt.clone(), Arc::clone(shared));
183        shown.ongoing = Some(sioul_sync::notify::ongoing(&note.title, &note.text, buttons, Box::new(move |key: &str| pressed(&qt, &shared, key))));
184    }
185}
186
187#[cfg(target_os = "android")]
188// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
189unsafe extern "C" {
190    /// The note in Android's notifications, put up or changed; "" takes it
191    /// away (android/main.cpp). Any thread.
192    fn sioul_android_time_note(json: *const c_char);
193}
194
195/// The note put up, changed, or taken away: Android's ongoing notification.
196#[cfg(target_os = "android")]
197fn put(_shown: &mut Shown, note: Option<&Note>, _window: Option<(&QtThread, &Arc<Shared>)>) {
198    let Ok(text) = std::ffi::CString::new(note.map_or_else(String::new, |n| crate::backend::json(n))) else { return };
199    // SAFETY: a zero-terminated text, valid for the call.
200    unsafe { sioul_android_time_note(text.as_ptr()) };
201}
202
203/// A button of the desktop's notification, on its thread: as the focus
204/// window's own; a click on it brings Sioul forward, on the task.
205#[cfg(not(target_os = "android"))]
206fn pressed(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
207    if key != "default" {
208        act(key, Some((qt, shared)));
209        post(Some((qt, shared)));
210        return;
211    }
212    let Some(uid) = timelog::running().map(|r| r.task).filter(|t| !t.is_empty()) else { return };
213    let uri = sioul_core::links::task_uri(&uid);
214    let _ = qt.queue(move |mut sioul| sioul.as_mut().reminder_opened(cxx_qt_lib::QString::from("task"), cxx_qt_lib::QString::from(&uri), cxx_qt_lib::QString::from(&uid)));
215}
216
217/// A button pressed, "pause", "resume" or "stop": with the window, as its own
218/// buttons (`work::focus_pause`, `work::focus_stop`); without, on the
219/// session's file. A button a moment behind (another device paused the
220/// session meanwhile) changes nothing: "pause" pauses only what runs,
221/// "resume" goes on only with what is paused. Returns whether it changed.
222fn act(action: &str, window: Option<(&QtThread, &Arc<Shared>)>) -> bool {
223    let _acting = ACTING.lock().unwrap_or_else(PoisonError::into_inner);
224    let Some((qt, shared)) = window else {
225        return act_in(&timelog::folder(), &Stopped::default_path(), action, Timestamp::now().as_second()).unwrap_or_else(|e| {
226            eprintln!("Sioul: the time running: {e}");
227            false
228        });
229    };
230    let Some(running) = timelog::running() else { return false };
231    match action {
232        "pause" | "resume" if running.paused_at.is_some() == (action == "resume") => crate::work::focus_pause(qt, shared),
233        "stop" => drop(crate::work::focus_stop(qt, shared, false, "")),
234        _ => return false,
235    }
236    true
237}
238
239/// A button pressed without the window: the session's file in `dir` changed
240/// as the window's buttons change it; Stop keeps the line left at a pause of
241/// this session (`stopped`), as the window's Stop with no word does.
242fn act_in(dir: &Path, stopped: &Path, action: &str, now: i64) -> Result<bool, String> {
243    let Some(running) = timelog::running_in(dir) else { return Ok(false) };
244    match action {
245        // Over the session as the file holds it now, under its lock: a pause pressed elsewhere meanwhile is not undone.
246        "pause" | "resume" if running.paused_at.is_some() == (action == "resume") => timelog::change_running_in(dir, |running| {
247            if running.paused_at.is_some() == (action == "resume") {
248                running.toggle_pause(now);
249            }
250        }),
251        "stop" => {
252            let left = Stopped::load(stopped).filter(|s| s.task == running.task && s.at >= running.start).map(|s| s.text).unwrap_or_default();
253            timelog::finish_noted_in(dir, now, false, &left).map(|session| session.is_some())
254        }
255        _ => Ok(false),
256    }
257}
258
259/// A button of Android's notification, "pause", "resume" or "stop", pressed,
260/// Sioul's window running or not (TimeReceiver.java): done here first, as the
261/// window would, so that nothing slow can lose it (Android lets the button go
262/// after a few seconds, and may stop Sioul then); then sent at once, with what
263/// your other devices did, in an exchange without notes and papers; the
264/// notification then put up as it follows, in the order the buttons were
265/// pressed. Answers that note as JSON, "" when no session is left. Blocks
266/// while the sharing exchanges: never on Android's main thread.
267///
268/// # Safety
269/// `action` is null, or a zero-terminated text valid for the call.
270// SAFETY: no other symbol of the program has this name.
271#[unsafe(no_mangle)]
272pub unsafe extern "C" fn sioul_time_action(action: *const c_char) -> *mut c_char {
273    // SAFETY: as the caller promises.
274    let action = unsafe { crate::alarms::key_of(action) };
275    let answer = std::panic::catch_unwind(|| {
276        let window = WINDOW.lock().ok().and_then(|w| w.clone());
277        let window = window.as_ref().map(|(qt, shared)| (qt, shared));
278        act(&action, window);
279        let _ = crate::share::exchange_here(false);
280        post(window).map(|note| crate::backend::json(&note)).unwrap_or_default()
281    })
282    .unwrap_or_else(|_| {
283        eprintln!("Sioul: the time running: \"{action}\" broke");
284        String::new()
285    });
286    crate::alarms::handed(answer)
287}
288
289/// Sioul quits: the computer's notification goes with it, its buttons having
290/// nothing left to reach. A phone's stays: its buttons reach Sioul without
291/// the window.
292pub(crate) fn closing() {
293    #[cfg(not(target_os = "android"))]
294    {
295        let ongoing = {
296            let mut shown = SHOWN.lock().unwrap_or_else(PoisonError::into_inner);
297            shown.over = true;
298            shown.ongoing.take()
299        };
300        if let Some(ongoing) = ongoing {
301            ongoing.close(std::time::Duration::from_secs(1));
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn at(text: &str) -> i64 {
311        format!("{text}[Europe/Paris]").parse::<jiff::Zoned>().unwrap().timestamp().as_second()
312    }
313
314    #[test]
315    fn the_note_says_the_task_since_when_and_its_buttons() {
316        let (zone, tr) = (TimeZone::get("Europe/Paris").unwrap(), Translator::new("en"));
317        let start = at("2026-10-05T14:02");
318        let mut running = Running { task: "t1".into(), start, planned: 25, ..Running::default() };
319        let note = note_of(Some(&running), "Write to the bank", start + 60, &tr, &zone).unwrap();
320        assert_eq!((note.title.as_str(), note.text.as_str(), note.paused, note.since), ("Write to the bank", "Since 14:02, 25 min chosen", false, start * 1000));
321        assert_eq!((note.pause.as_str(), note.stop.as_str(), note.channel.as_str()), ("Pause", "Stop", "Focus timer"));
322        // Paused after twelve minutes: the time so far, no chronometer, "Go on".
323        running.toggle_pause(start + 12 * 60);
324        let note = note_of(Some(&running), "Write to the bank", start + 20 * 60, &tr, &zone).unwrap();
325        assert_eq!((note.text.as_str(), note.paused, note.since, note.pause.as_str()), ("Paused, 12 min so far", true, 0, "Go on"));
326        // Going on after eight minutes paused: the chronometer counts from eight minutes later.
327        running.toggle_pause(start + 20 * 60);
328        assert_eq!(note_of(Some(&running), "Write to the bank", start + 21 * 60, &tr, &zone).unwrap().since, (start + 8 * 60) * 1000);
329        // No title known here, no end chosen: "Focus", without an end; in French too.
330        running.planned = 0;
331        let note = note_of(Some(&running), " ", start + 21 * 60, &Translator::new("fr"), &zone).unwrap();
332        assert_eq!((note.title.as_str(), note.text.as_str(), note.pause.as_str(), note.stop.as_str()), ("Concentration", "Depuis 14:02, sans fin fixée", "Pause", "Arrêter"));
333        // No session, no note.
334        assert_eq!(note_of(None, "Write to the bank", start, &tr, &zone), None);
335    }
336
337    #[test]
338    fn titles_from_the_task_else_its_project() {
339        let task = |uid: &str, title: &str, projects: &[&str]| Task { uid: uid.into(), title: title.into(), projects: projects.iter().map(|c| c.to_string()).collect(), ..Task::default() };
340        let tasks = vec![task("t1", "Write to the bank", &["lumen"]), task("t2", "", &["gone", "lumen"])];
341        let projects = vec![Project { id: "lumen".into(), title: "Lumen, the website".into(), ..Project::default() }];
342        assert_eq!(title_of("t1", &tasks, &projects), "Write to the bank");
343        assert_eq!(title_of("t2", &tasks, &projects), "Lumen, the website");
344        assert_eq!(title_of("t3", &tasks, &projects), "", "a task not here yet");
345    }
346
347    #[test]
348    fn its_buttons_pause_go_on_and_stop_once() {
349        let dir = std::env::temp_dir().join(format!("sioul-timenote-{}", std::process::id()));
350        let _ = std::fs::remove_dir_all(&dir);
351        let stopped = dir.join("stopped.toml");
352        let (zone, tr) = (TimeZone::get("Europe/Paris").unwrap(), Translator::new("en"));
353        let note = |now: i64| note_of(timelog::running_in(&dir).as_ref(), "Write to the bank", now, &tr, &zone);
354        let start = at("2026-10-05T09:00");
355        // Started: the note, with its task.
356        timelog::keep_running_in(&dir, Some(&Running { task: "t1".into(), start, planned: 25, ..Running::default() })).unwrap();
357        assert_eq!(note(start).map(|n| (n.title, n.paused)), Some(("Write to the bank".to_string(), false)));
358        // Going on with what runs changes nothing; paused, the note says so.
359        assert!(!act_in(&dir, &stopped, "resume", start + 60).unwrap());
360        assert!(act_in(&dir, &stopped, "pause", start + 600).unwrap());
361        assert_eq!(note(start + 700).map(|n| (n.text, n.paused)), Some(("Paused, 10 min so far".to_string(), true)));
362        assert!(!act_in(&dir, &stopped, "pause", start + 700).unwrap(), "a button a moment behind changes nothing");
363        assert_eq!(timelog::running_in(&dir).unwrap().paused_at, Some(start + 600));
364        assert!(act_in(&dir, &stopped, "resume", start + 900).unwrap());
365        assert!(note(start + 900).is_some_and(|n| !n.paused && n.since == (start + 300) * 1000));
366        // Stopped: kept, with the line left at its pause; no note left.
367        Stopped::keep(&stopped, "the second paragraph", "t1", start + 610).unwrap();
368        assert!(act_in(&dir, &stopped, "stop", start + 1500).unwrap());
369        assert_eq!(note(start + 1500), None);
370        let kept = timelog::sessions_in(&dir);
371        assert_eq!((kept.len(), kept[0].minutes, kept[0].note.as_str(), kept[0].done), (1, 20, "the second paragraph", false));
372        assert!(!act_in(&dir, &stopped, "stop", start + 1600).unwrap(), "nothing left to stop");
373        assert!(!act_in(&dir, &stopped, "jump", start + 1600).unwrap());
374        std::fs::remove_dir_all(&dir).unwrap();
375    }
376}