Skip to main content

sioul_app/
eventalarms.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Reminders before events on a phone (docs/android.md, "Events"): Android
5//! freezes an app it does not show, so the coming reminders of events (their
6//! own alarms, Sioul's before them, the working day before) are given ahead
7//! to Android's alarm clock (android/package/src/com/aurelienpierre/sioul/
8//! EventAlarms.java), as the doses are (`alarms`): at the window's minute,
9//! when Sioul is put away, after each ring. At its time a receiver asks here
10//! what to say (`sioul_event_decide`): the event read again (moved,
11//! cancelled, told already?), sleep or a pause holding it or not, the time
12//! left said as it is then; shown in the quiet "Events" channel, a tap
13//! opening the event. Every device tells its own: one held back on the phone
14//! because the computer was used a moment ago, then shown on an empty desk,
15//! would be an appointment missed.
16//!
17//! Also here, Android's side of new mail's notification (`mail_note`,
18//! MailNotes.java), and what a tapped notification opens (`opened`).
19//! Elsewhere nothing: the window's minute and `sioul remind --watch` tell them.
20
21use crate::backend::{load_config, tr};
22use jiff::Zoned;
23use jiff::tz::TimeZone;
24use sioul_core::reminders::{self, Holds, Kind, Reminder};
25use std::ffi::{CString, c_char};
26use std::sync::Mutex;
27
28#[cfg(target_os = "android")]
29// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
30unsafe extern "C" {
31    /// The coming events' reminders handed to Java, which keeps them and sets their alarms (android/main.cpp).
32    fn sioul_android_set_event_alarms(json: *const c_char);
33    /// A reminder shown in Android's notifications, "Events" (android/main.cpp).
34    fn sioul_android_event_note(json: *const c_char);
35    /// New mail's notification, "New mail" (android/main.cpp).
36    fn sioul_android_mail_note(json: *const c_char);
37    /// The end of work's notice or the night's, with its buttons, "End of the day" (android/main.cpp).
38    fn sioul_android_day_note(json: *const c_char);
39    /// What a tapped notification opens, once, as JSON {kind, key}; false when nothing (android/main.cpp).
40    fn sioul_android_take_reminder_opened(buffer: *mut c_char, size: i32) -> bool;
41}
42
43/// At most this many alarms given: Android keeps 500 per app, the doses' among them.
44const AT_MOST: usize = 100;
45/// Java asks again this long after the list was given, when nothing rang meanwhile…
46const LOOK_AGAIN: i64 = 24 * 3600;
47/// …in steps of this, so that the same list is not handed again each minute.
48const LOOK_STEP: i64 = 6 * 3600;
49/// A reminder held while nothing says when it may come (Free time ended, say) is asked about again this often.
50const AGAIN: i64 = 15 * 60;
51
52/// The list last handed to Java, so that it is handed again only when it changes.
53static GIVEN: Mutex<String> = Mutex::new(String::new());
54
55/// The words Java says, in your language, given with the list: Sioul may
56/// not be running when a reminder is shown.
57fn words() -> serde_json::Value {
58    serde_json::json!({
59        "channel": tr().text("events-channel", None),
60        "mail": tr().text("mail-channel", None),
61        "mail-through": tr().text("mail-through-channel", None),
62        "alarms": tr().text("alarms-channel", None),
63        "alarms-pause": tr().text("dnd-events-channel", None),
64        "day": tr().text("review-channel", None),
65        "open": tr().text("reminder-open", None),
66    })
67}
68
69/// What Java is given: the events' reminders not told yet that still make
70/// sense, nearest first, at most a hundred, each {key, at (Unix
71/// milliseconds), title, body} (the words said if Sioul cannot be asked at
72/// its time); one held now (you sleep) at the time it may come; `look`, when
73/// to ask for the list again (a day on, in steps of six hours, so that the
74/// list given is the same from one minute to the next); `words`.
75pub(crate) fn coming(now: &Zoned) -> String {
76    let (all, holds) = reminders::gather_events(&load_config(), tr(), now);
77    listed(&all, &crate::hours::with_layers(holds), now)
78}
79
80/// The list Java is given, from reminders gathered already: the events' own,
81/// then the times the end-of-work notice and the night's may come
82/// (`reviews::coming_work`, `health::coming_night`), "day" their kind, with
83/// no words: Rust alone says them, or nothing (`day_note`).
84fn listed(all: &[Reminder], holds: &Holds, now: &Zoned) -> String {
85    let stamp = now.timestamp().as_second();
86    let dir = reminders::told_dir();
87    let mut due: Vec<(i64, &Reminder)> = all
88        .iter()
89        .filter(|r| matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm) && r.until > stamp && !reminders::told(&dir, &r.key))
90        .map(|r| (when(r, holds, stamp), r))
91        .filter(|(at, r)| *at < r.until)
92        .collect();
93    due.sort_by_key(|(at, r)| (*at, r.key.clone()));
94    let mut list: Vec<serde_json::Value> = due.iter().take(AT_MOST).map(|(at, r)| serde_json::json!({ "key": r.key, "kind": r.kind, "at": at * 1000, "title": r.title, "body": r.body })).collect();
95    let days = crate::reviews::coming_work(now).into_iter().chain(crate::health::coming_night(now));
96    list.extend(days.map(|(key, at)| serde_json::json!({ "key": key, "kind": "day", "at": at * 1000, "title": "", "body": "" })));
97    let look = (stamp / LOOK_STEP + LOOK_AGAIN / LOOK_STEP) * LOOK_STEP;
98    serde_json::json!({ "reminders": list, "look": look * 1000, "words": words() }).to_string()
99}
100
101/// When a reminder's alarm rings (Unix seconds): its time; past it, at once
102/// when nothing holds it, else when the hold ends (waking).
103fn when(reminder: &Reminder, holds: &Holds, stamp: i64) -> i64 {
104    if reminder.at > stamp {
105        reminder.at
106    } else if reminder.ready_in(stamp, holds) {
107        stamp + 1
108    } else {
109        again_at(holds, stamp)
110    }
111}
112
113/// The list handed to Java when it changed since it was last.
114fn hand(json: String) {
115    let Ok(mut given) = GIVEN.lock() else { return };
116    if *given == json {
117        return;
118    }
119    let Ok(text) = CString::new(json.clone()) else { return };
120    #[cfg(target_os = "android")]
121    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
122    unsafe {
123        sioul_android_set_event_alarms(text.as_ptr())
124    };
125    let _ = text;
126    *given = json;
127}
128
129/// The coming events' reminders given to Android's alarm clock when they
130/// changed: as Sioul is put away, and after each ring. Nothing elsewhere.
131pub(crate) fn schedule() {
132    if !cfg!(target_os = "android") {
133        return;
134    }
135    hand(coming(&Zoned::now()));
136}
137
138/// The same from the window's minute, with the reminders it gathered
139/// (`remind::tick`): the calendars read once a minute, not twice.
140pub(crate) fn schedule_from(all: &[Reminder], holds: &Holds, now: &Zoned) {
141    if !cfg!(target_os = "android") {
142        return;
143    }
144    hand(listed(all, holds, now));
145}
146
147/// A reminder shown in Android's notifications, "Events": its words, "Open",
148/// a tap opening the event; gone from the shade an hour after the event began.
149pub(crate) fn show(reminder: &Reminder) {
150    let json = serde_json::json!({
151        "key": reminder.key,
152        "kind": reminder.kind,
153        "title": reminder.title,
154        "body": reminder.body,
155        "event": reminder.target,
156        "until": reminder.until * 1000,
157        "words": words(),
158    });
159    let Ok(text) = CString::new(json.to_string()) else { return };
160    #[cfg(target_os = "android")]
161    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
162    unsafe {
163        sioul_android_event_note(text.as_ptr())
164    };
165    let _ = text;
166}
167
168/// New mail's notification on a phone (`mailnote`), in the quiet "New mail"
169/// channel: one at a time, the next batch in its place; a tap opens the Porch.
170/// `through`: someone Always through is in it, whom the system's
171/// do-not-disturb should let through (a channel that passes, Java's to choose).
172#[cfg_attr(not(target_os = "android"), allow(dead_code))]
173pub(crate) fn mail_note(title: &str, body: &str, through: bool) {
174    let json = serde_json::json!({ "title": title, "body": body, "through": through, "words": words() });
175    let Ok(text) = CString::new(json.to_string()) else { return };
176    #[cfg(target_os = "android")]
177    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
178    unsafe {
179        sioul_android_mail_note(text.as_ptr())
180    };
181    let _ = text;
182}
183
184/// The end of work's notice or the night's on a phone (`reviews::decide_work`,
185/// `health::decide_night`), in the quiet "End of the day" channel: its words,
186/// and each button ({label, kind, key}) opening Sioul on what it offers, a tap
187/// on the notice the first's (DayNotes.java); gone from the shade at `until`
188/// (Unix seconds). Shown again in its place, by `key`, when it comes again.
189pub(crate) fn day_note(key: &str, title: &str, body: &str, offered: &[crate::reviews::Offered], until: i64) {
190    let json = day_note_json(key, title, body, offered, until, words());
191    let Ok(text) = CString::new(json.to_string()) else { return };
192    #[cfg(target_os = "android")]
193    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
194    unsafe {
195        sioul_android_day_note(text.as_ptr())
196    };
197    let _ = text;
198}
199
200/// What DayNotes.java reads of a notice (`day_note`): {key, title, body,
201/// buttons: [{label, kind, key}], until (Unix ms), words}.
202fn day_note_json(key: &str, title: &str, body: &str, offered: &[crate::reviews::Offered], until: i64, words: serde_json::Value) -> serde_json::Value {
203    let buttons: Vec<serde_json::Value> = offered.iter().map(|offer| serde_json::json!({ "label": offer.label, "kind": offer.kind, "key": offer.key })).collect();
204    serde_json::json!({ "key": key, "title": title, "body": body, "buttons": buttons, "until": until * 1000, "words": words })
205}
206
207/// When a reminder held now may come: when the time now ends (waking, the
208/// end of free time, the hours' end), a minute on at the soonest; while a
209/// layer holds it (a slot of time for you, do-not-disturb) or the end is
210/// unknown (a pause), at the next quarter of an hour (Unix seconds). In
211/// steps, never "a minute from now": the list given to Java stays the same
212/// from one call to the next, so it is not given again, its alarms set again,
213/// every second.
214fn again_at(holds: &Holds, stamp: i64) -> i64 {
215    match holds.until {
216        Some(until) if !holds.now.slot && !holds.now.dnd => until.max(step_after(stamp, 60)),
217        _ => step_after(stamp, AGAIN),
218    }
219}
220
221/// The first multiple of `step` seconds after `stamp`.
222fn step_after(stamp: i64, step: i64) -> i64 {
223    (stamp.div_euclid(step) + 1) * step
224}
225
226/// At a reminder's time (`EventAlarms`): {"shown": true} once told here; or
227/// {"again_at": ms}, held now (you sleep) and still worth telling then; or
228/// {} (moved, cancelled, told already, too late). The coming list is given
229/// again after, as the calendars are now. The end of work's and the night's
230/// ("day:work:…", "day:night:…") are theirs to decide.
231fn decide(key: &str) -> String {
232    let now = Zoned::now();
233    if let Some(day) = key.strip_prefix("day:") {
234        let answer = if day.starts_with("work:") {
235            crate::reviews::decide_work(&now)
236        } else if day.starts_with("night:") {
237            crate::health::decide_night(&now)
238        } else {
239            serde_json::json!({})
240        };
241        schedule();
242        return answer.to_string();
243    }
244    let stamp = now.timestamp().as_second();
245    let (all, holds) = reminders::gather_events(&load_config(), tr(), &now);
246    let holds = crate::hours::with_layers(holds);
247    let dir = reminders::told_dir();
248    let answer = match all.iter().find(|r| r.key == key) {
249        Some(r) if r.ready_in(stamp, &holds) => {
250            // Told by the window's minute already: nothing more.
251            let shown = reminders::claim(&dir, &r.key);
252            if shown {
253                show(r);
254            }
255            serde_json::json!({ "shown": shown })
256        }
257        // Not yet due (an alarm a moment early), or held now (you sleep): asked again then.
258        Some(r) if stamp < r.until && !reminders::told(&dir, &r.key) => {
259            let again = when(r, &holds, stamp).max(stamp + 1);
260            if again < r.until { serde_json::json!({ "again_at": again * 1000 }) } else { serde_json::json!({}) }
261        }
262        _ => serde_json::json!({}),
263    };
264    schedule();
265    answer.to_string()
266}
267
268/// `decide`, for Java (android/main.cpp); a panic answers `null`, and Java
269/// shows the words it was given with the list rather than nothing. Given back
270/// to `sioul_string_free`.
271///
272/// # Safety
273/// `key` is null, or a zero-terminated text valid for the call.
274// SAFETY: no other symbol of the program has this name.
275#[unsafe(no_mangle)]
276pub unsafe extern "C" fn sioul_event_decide(key: *const c_char) -> *mut c_char {
277    // SAFETY: `key` is null or a C string valid for the call, as this function's contract says.
278    let key = unsafe { crate::alarms::key_of(key) };
279    let answer = std::panic::catch_unwind(|| decide(&key)).unwrap_or_else(|_| "null".to_string());
280    crate::alarms::handed(answer)
281}
282
283/// The coming list, for Java (`EventAlarms.ask`: after a restart, an update,
284/// a change of time or of zone, and once a day), in `zone`, the phone's as
285/// Java has it; "" when it fails, and Java keeps its list.
286///
287/// # Safety
288/// `zone` is null, or a zero-terminated text valid for the call.
289// SAFETY: no other symbol of the program has this name.
290#[unsafe(no_mangle)]
291pub unsafe extern "C" fn sioul_event_coming(zone: *const c_char) -> *mut c_char {
292    // SAFETY: `zone` is null or a C string valid for the call, as this function's contract says.
293    let zone = unsafe { crate::alarms::key_of(zone) };
294    let answer = std::panic::catch_unwind(|| {
295        let zone = TimeZone::get(&zone).unwrap_or_else(|_| TimeZone::system());
296        let json = coming(&Zoned::now().with_time_zone(zone));
297        if let Ok(mut given) = GIVEN.lock() {
298            *given = json.clone();
299        }
300        json
301    })
302    .unwrap_or_default();
303    // The calls' table too, at this daily look: its frames never run out while Sioul stays closed.
304    let _ = std::panic::catch_unwind(|| crate::calls::refresh(false));
305    crate::alarms::handed(answer)
306}
307
308/// What a notification tapped while Sioul was away opens, once: ("event",
309/// its file), ("porch", ""), a closing sheet ("review", "work" or "night",
310/// with its day: "work:2026-10-14"), or a block's question ("need", its key).
311pub(crate) fn opened() -> Option<(String, String)> {
312    #[cfg(target_os = "android")]
313    {
314        let mut buffer = vec![0u8; 4096];
315        // SAFETY: android/main.cpp's; it writes at most `buffer.len()` bytes into `buffer`, its zero included.
316        let found = unsafe { sioul_android_take_reminder_opened(buffer.as_mut_ptr().cast(), buffer.len() as i32) };
317        if found {
318            let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len());
319            return serde_json::from_slice(&buffer[..end]).ok().and_then(|said| opened_of(&said));
320        }
321    }
322    None
323}
324
325/// What a tapped notification's {kind, key} opens (ReminderOpener.java keeps
326/// it): only what Sioul's own notifications open; none for anything else.
327#[cfg_attr(not(target_os = "android"), allow(dead_code))]
328fn opened_of(said: &serde_json::Value) -> Option<(String, String)> {
329    let key = said["key"].as_str().unwrap_or_default();
330    let kind = said["kind"].as_str().filter(|kind| match *kind {
331        "event" | "porch" | "need" => true,
332        "review" => {
333            let (sheet, day) = key.split_once(':').map_or((key, None), |(sheet, day)| (sheet, Some(day)));
334            matches!(sheet, "work" | "night") && day.is_none_or(|day| day.parse::<jiff::civil::Date>().is_ok())
335        }
336        _ => false,
337    })?;
338    Some((kind.to_string(), key.to_string()))
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn held_reminders_are_asked_about_again_when_the_hold_ends() {
347        use sioul_core::reminders::Wait;
348        let stamp = 1_800_000_000;
349        let usual = Holds::usual;
350        assert_eq!(again_at(&usual(Wait::Sleep { from: stamp - 3600, until: stamp + 7200 }), stamp), stamp + 7200, "at waking");
351        assert_eq!(again_at(&usual(Wait::Free { until: stamp + 600 }), stamp), stamp + 600, "at the end of free time");
352        assert_eq!(again_at(&usual(Wait::Free { until: stamp }), stamp), stamp + 60, "never at once again");
353        assert_eq!(again_at(&usual(Wait::Paused), stamp), stamp + AGAIN);
354        // In steps: the same from one second to the next, so the list given to Java stays the same.
355        for later in 0..AGAIN {
356            assert_eq!(again_at(&usual(Wait::Paused), stamp + later), stamp + AGAIN, "{later} s on");
357        }
358        for later in 0..60 {
359            assert_eq!(again_at(&usual(Wait::Free { until: stamp }), stamp + later), stamp + 60, "{later} s on");
360        }
361        assert_eq!(again_at(&usual(Wait::Paused), stamp + AGAIN), stamp + 2 * AGAIN, "never now");
362        // A reminder's alarm: its time; due and free, at once; held (a train after waking), at waking.
363        let reminder = |at: i64, until: i64| Reminder { key: "before:train:1".into(), kind: Kind::Before, at, until, title: String::new(), body: String::new(), target: String::new(), work: false, starts: Some(until) };
364        let night = usual(Wait::Sleep { from: stamp - 8 * 3600, until: stamp + 900 });
365        assert_eq!(when(&reminder(stamp + 600, stamp + 3600), &usual(Wait::Nothing), stamp), stamp + 600);
366        assert_eq!(when(&reminder(stamp - 60, stamp + 3600), &usual(Wait::Nothing), stamp), stamp + 1);
367        assert_eq!(when(&reminder(stamp - 60, stamp + 3600), &night, stamp), stamp + 900, "kept in the list given, at waking");
368        // Held during do-not-disturb (the matrix changed): asked again in a quarter of an hour.
369        let mut focus = usual(Wait::Nothing);
370        focus.now.dnd = true;
371        focus.attention.set(sioul_core::attention::Row::Own(sioul_core::attention::Kind::Before), sioul_core::attention::Column::Dnd, sioul_core::attention::Level::Later).unwrap();
372        assert_eq!(when(&reminder(stamp - 60, stamp + 3600), &focus, stamp), stamp + AGAIN);
373    }
374
375    /// A tapped notification opens only what Sioul's own open: an event, the
376    /// Porch, a closing sheet (the work day's or the whole day's: the end of
377    /// work's and the night's notices), a block's question; nothing else.
378    #[test]
379    fn a_tapped_notice_opens_only_what_sioul_offers() {
380        let of = |json: &str| opened_of(&serde_json::from_str(json).unwrap());
381        assert_eq!(of(r#"{"kind":"review","key":"work"}"#), Some(("review".into(), "work".into())));
382        assert_eq!(of(r#"{"kind":"review","key":"night"}"#), Some(("review".into(), "night".into())));
383        assert_eq!(of(r#"{"kind":"need","key":"sleep"}"#), Some(("need".into(), "sleep".into())));
384        assert_eq!(of(r#"{"kind":"event","key":"work/1.ics"}"#), Some(("event".into(), "work/1.ics".into())));
385        assert_eq!(of(r#"{"kind":"porch"}"#), Some(("porch".into(), String::new())));
386        assert_eq!(of(r#"{"kind":"review","key":"work:2026-10-14"}"#), Some(("review".into(), "work:2026-10-14".into())));
387        assert_eq!(of(r#"{"kind":"review","key":"night:2026-10-14"}"#), Some(("review".into(), "night:2026-10-14".into())));
388        assert_eq!(of(r#"{"kind":"review","key":"work:yesterday"}"#), None);
389        assert_eq!(of(r#"{"kind":"review","key":"settings"}"#), None);
390        assert_eq!(of(r#"{"kind":"settings:words","key":""}"#), None);
391        assert_eq!(of(r#"{"key":"work"}"#), None);
392    }
393
394    /// A Java string literal's text: its `\"` and `\uXXXX` read.
395    fn unescaped(literal: &str) -> String {
396        let mut out = String::new();
397        let mut chars = literal.chars();
398        while let Some(c) = chars.next() {
399            if c != '\\' {
400                out.push(c);
401                continue;
402            }
403            match chars.next() {
404                Some('u') => {
405                    let hex: String = chars.by_ref().take(4).collect();
406                    out.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32));
407                }
408                Some(other) => out.push(other),
409                None => {}
410            }
411        }
412        out
413    }
414
415    /// The end of work's notice and the night's as Rust gives them to Java
416    /// (`day_note`), in English: the very words android/jvm-checks/
417    /// DayNotesCheck.java checks DayNotes.buttons on (WORK, NIGHT, GIVEN),
418    /// read from that file, so that neither side changes alone.
419    #[test]
420    fn the_days_notices_as_java_reads_them() {
421        let check = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../android/jvm-checks/DayNotesCheck.java")).unwrap();
422        let java = |name: &str| -> serde_json::Value {
423            let start = format!("static final String {name} = \"");
424            let line = check.lines().map(str::trim).find(|line| line.starts_with(&start)).unwrap();
425            let literal = line[start.len()..].strip_suffix("\";").unwrap();
426            serde_json::from_str(&unescaped(literal)).unwrap()
427        };
428        let en = sioul_core::i18n::Translator::new("en");
429        let words = serde_json::json!({ "day": en.text("review-channel", None) });
430        let offer = |label: &str, kind: &'static str, key: &str| crate::reviews::Offered { label: en.text(label, None), kind, key: key.into() };
431        let until = 1_792_000_000;
432        let work = day_note_json("day:work:2026-10-14", &en.text("review-notice-title", None), &en.text("review-notice-text", None), &[offer("review-close-work", "review", "work:2026-10-14")], until, words.clone());
433        assert_eq!(work, java("WORK"));
434        let night = day_note_json("day:night:2026-10-14", "Wind down", "22:30", &[offer("review-close-night", "review", "night:2026-10-14"), offer("need-open", "need", "sleep")], until, words.clone());
435        assert_eq!(night, java("NIGHT"));
436        let given = day_note_json("day:night:2026-10-14", "Wind down", "22:30", &[offer("need-open", "need", "sleep")], until, words);
437        assert_eq!(given, java("GIVEN"));
438    }
439}