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    /// What a tapped notification opens, once, as JSON {kind, key}; false when nothing (android/main.cpp).
38    fn sioul_android_take_reminder_opened(buffer: *mut c_char, size: i32) -> bool;
39}
40
41/// At most this many alarms given: Android keeps 500 per app, the doses' among them.
42const AT_MOST: usize = 100;
43/// Java asks again this long after the list was given, when nothing rang meanwhile…
44const LOOK_AGAIN: i64 = 24 * 3600;
45/// …in steps of this, so that the same list is not handed again each minute.
46const LOOK_STEP: i64 = 6 * 3600;
47/// A reminder held while nothing says when it may come (Free time ended, say) is asked about again this often.
48const AGAIN: i64 = 15 * 60;
49
50/// The list last handed to Java, so that it is handed again only when it changes.
51static GIVEN: Mutex<String> = Mutex::new(String::new());
52
53/// The words Java says, in your language, given with the list: Sioul may
54/// not be running when a reminder is shown.
55fn words() -> serde_json::Value {
56    serde_json::json!({
57        "channel": tr().text("events-channel", None),
58        "mail": tr().text("mail-channel", None),
59        "mail-through": tr().text("mail-through-channel", None),
60        "alarms": tr().text("alarms-channel", None),
61        "alarms-pause": tr().text("dnd-events-channel", None),
62        "open": tr().text("reminder-open", None),
63    })
64}
65
66/// What Java is given: the events' reminders not told yet that still make
67/// sense, nearest first, at most a hundred, each {key, at (Unix
68/// milliseconds), title, body} (the words said if Sioul cannot be asked at
69/// its time); one held now (you sleep) at the time it may come; `look`, when
70/// to ask for the list again (a day on, in steps of six hours, so that the
71/// list given is the same from one minute to the next); `words`.
72pub(crate) fn coming(now: &Zoned) -> String {
73    let (all, holds) = reminders::gather_events(&load_config(), tr(), now);
74    listed(&all, &crate::hours::with_layers(holds), now)
75}
76
77/// The list Java is given, from reminders gathered already: the events' own.
78fn listed(all: &[Reminder], holds: &Holds, now: &Zoned) -> String {
79    let stamp = now.timestamp().as_second();
80    let dir = reminders::told_dir();
81    let mut due: Vec<(i64, &Reminder)> = all
82        .iter()
83        .filter(|r| matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm) && r.until > stamp && !reminders::told(&dir, &r.key))
84        .map(|r| (when(r, holds, stamp), r))
85        .filter(|(at, r)| *at < r.until)
86        .collect();
87    due.sort_by_key(|(at, r)| (*at, r.key.clone()));
88    let 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();
89    let look = (stamp / LOOK_STEP + LOOK_AGAIN / LOOK_STEP) * LOOK_STEP;
90    serde_json::json!({ "reminders": list, "look": look * 1000, "words": words() }).to_string()
91}
92
93/// When a reminder's alarm rings (Unix seconds): its time; past it, at once
94/// when nothing holds it, else when the hold ends (waking).
95fn when(reminder: &Reminder, holds: &Holds, stamp: i64) -> i64 {
96    if reminder.at > stamp {
97        reminder.at
98    } else if reminder.ready_in(stamp, holds) {
99        stamp + 1
100    } else {
101        again_at(holds, stamp)
102    }
103}
104
105/// The list handed to Java when it changed since it was last.
106fn hand(json: String) {
107    let Ok(mut given) = GIVEN.lock() else { return };
108    if *given == json {
109        return;
110    }
111    let Ok(text) = CString::new(json.clone()) else { return };
112    #[cfg(target_os = "android")]
113    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
114    unsafe {
115        sioul_android_set_event_alarms(text.as_ptr())
116    };
117    let _ = text;
118    *given = json;
119}
120
121/// The coming events' reminders given to Android's alarm clock when they
122/// changed: as Sioul is put away, and after each ring. Nothing elsewhere.
123pub(crate) fn schedule() {
124    if !cfg!(target_os = "android") {
125        return;
126    }
127    hand(coming(&Zoned::now()));
128}
129
130/// The same from the window's minute, with the reminders it gathered
131/// (`remind::tick`): the calendars read once a minute, not twice.
132pub(crate) fn schedule_from(all: &[Reminder], holds: &Holds, now: &Zoned) {
133    if !cfg!(target_os = "android") {
134        return;
135    }
136    hand(listed(all, holds, now));
137}
138
139/// A reminder shown in Android's notifications, "Events": its words, "Open",
140/// a tap opening the event; gone from the shade an hour after the event began.
141pub(crate) fn show(reminder: &Reminder) {
142    let json = serde_json::json!({
143        "key": reminder.key,
144        "kind": reminder.kind,
145        "title": reminder.title,
146        "body": reminder.body,
147        "event": reminder.target,
148        "until": reminder.until * 1000,
149        "words": words(),
150    });
151    let Ok(text) = CString::new(json.to_string()) else { return };
152    #[cfg(target_os = "android")]
153    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
154    unsafe {
155        sioul_android_event_note(text.as_ptr())
156    };
157    let _ = text;
158}
159
160/// New mail's notification on a phone (`mailnote`), in the quiet "New mail"
161/// channel: one at a time, the next batch in its place; a tap opens the Porch.
162/// `through`: someone Always through is in it, whom the system's
163/// do-not-disturb should let through (a channel that passes, Java's to choose).
164#[cfg_attr(not(target_os = "android"), allow(dead_code))]
165pub(crate) fn mail_note(title: &str, body: &str, through: bool) {
166    let json = serde_json::json!({ "title": title, "body": body, "through": through, "words": words() });
167    let Ok(text) = CString::new(json.to_string()) else { return };
168    #[cfg(target_os = "android")]
169    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
170    unsafe {
171        sioul_android_mail_note(text.as_ptr())
172    };
173    let _ = text;
174}
175
176/// When a reminder held now may come: when the time now ends (waking, the
177/// end of free time, the hours' end); while a layer holds it (a slot of time
178/// for you, do-not-disturb) or the end is unknown (a pause), in a quarter of
179/// an hour (Unix seconds).
180fn again_at(holds: &Holds, stamp: i64) -> i64 {
181    match holds.until {
182        Some(until) if !holds.now.slot && !holds.now.dnd => until.max(stamp + 60),
183        _ => stamp + AGAIN,
184    }
185}
186
187/// At a reminder's time (`EventAlarms`): {"shown": true} once told here; or
188/// {"again_at": ms}, held now (you sleep) and still worth telling then; or
189/// {} (moved, cancelled, told already, too late). The coming list is given
190/// again after, as the calendars are now.
191fn decide(key: &str) -> String {
192    let now = Zoned::now();
193    let stamp = now.timestamp().as_second();
194    let (all, holds) = reminders::gather_events(&load_config(), tr(), &now);
195    let holds = crate::hours::with_layers(holds);
196    let dir = reminders::told_dir();
197    let answer = match all.iter().find(|r| r.key == key) {
198        Some(r) if r.ready_in(stamp, &holds) => {
199            // Told by the window's minute already: nothing more.
200            let shown = reminders::claim(&dir, &r.key);
201            if shown {
202                show(r);
203            }
204            serde_json::json!({ "shown": shown })
205        }
206        // Not yet due (an alarm a moment early), or held now (you sleep): asked again then.
207        Some(r) if stamp < r.until && !reminders::told(&dir, &r.key) => {
208            let again = when(r, &holds, stamp).max(stamp + 1);
209            if again < r.until { serde_json::json!({ "again_at": again * 1000 }) } else { serde_json::json!({}) }
210        }
211        _ => serde_json::json!({}),
212    };
213    schedule();
214    answer.to_string()
215}
216
217/// `decide`, for Java (android/main.cpp); a panic answers `null`, and Java
218/// shows the words it was given with the list rather than nothing. Given back
219/// to `sioul_string_free`.
220///
221/// # Safety
222/// `key` is null, or a zero-terminated text valid for the call.
223// SAFETY: no other symbol of the program has this name.
224#[unsafe(no_mangle)]
225pub unsafe extern "C" fn sioul_event_decide(key: *const c_char) -> *mut c_char {
226    // SAFETY: `key` is null or a C string valid for the call, as this function's contract says.
227    let key = unsafe { crate::alarms::key_of(key) };
228    let answer = std::panic::catch_unwind(|| decide(&key)).unwrap_or_else(|_| "null".to_string());
229    crate::alarms::handed(answer)
230}
231
232/// The coming list, for Java (`EventAlarms.ask`: after a restart, an update,
233/// a change of time or of zone, and once a day), in `zone`, the phone's as
234/// Java has it; "" when it fails, and Java keeps its list.
235///
236/// # Safety
237/// `zone` is null, or a zero-terminated text valid for the call.
238// SAFETY: no other symbol of the program has this name.
239#[unsafe(no_mangle)]
240pub unsafe extern "C" fn sioul_event_coming(zone: *const c_char) -> *mut c_char {
241    // SAFETY: `zone` is null or a C string valid for the call, as this function's contract says.
242    let zone = unsafe { crate::alarms::key_of(zone) };
243    let answer = std::panic::catch_unwind(|| {
244        let zone = TimeZone::get(&zone).unwrap_or_else(|_| TimeZone::system());
245        let json = coming(&Zoned::now().with_time_zone(zone));
246        if let Ok(mut given) = GIVEN.lock() {
247            *given = json.clone();
248        }
249        json
250    })
251    .unwrap_or_default();
252    // The calls' table too, at this daily look: its frames never run out while Sioul stays closed.
253    let _ = std::panic::catch_unwind(|| crate::calls::refresh(false));
254    crate::alarms::handed(answer)
255}
256
257/// What a notification tapped while Sioul was away opens, once: ("event",
258/// its file) or ("porch", "").
259pub(crate) fn opened() -> Option<(String, String)> {
260    #[cfg(target_os = "android")]
261    {
262        let mut buffer = vec![0u8; 4096];
263        // SAFETY: android/main.cpp's; it writes at most `buffer.len()` bytes into `buffer`, its zero included.
264        let found = unsafe { sioul_android_take_reminder_opened(buffer.as_mut_ptr().cast(), buffer.len() as i32) };
265        if found {
266            let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len());
267            let said: serde_json::Value = serde_json::from_slice(&buffer[..end]).ok()?;
268            let kind = said["kind"].as_str().filter(|k| *k == "event" || *k == "porch")?.to_string();
269            return Some((kind, said["key"].as_str().unwrap_or_default().to_string()));
270        }
271    }
272    None
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn held_reminders_are_asked_about_again_when_the_hold_ends() {
281        use sioul_core::reminders::Wait;
282        let stamp = 1_800_000_000;
283        let usual = Holds::usual;
284        assert_eq!(again_at(&usual(Wait::Sleep { from: stamp - 3600, until: stamp + 7200 }), stamp), stamp + 7200, "at waking");
285        assert_eq!(again_at(&usual(Wait::Free { until: stamp + 600 }), stamp), stamp + 600, "at the end of free time");
286        assert_eq!(again_at(&usual(Wait::Free { until: stamp }), stamp), stamp + 60, "never at once again");
287        assert_eq!(again_at(&usual(Wait::Paused), stamp), stamp + AGAIN);
288        // A reminder's alarm: its time; due and free, at once; held (a train after waking), at waking.
289        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) };
290        let night = usual(Wait::Sleep { from: stamp - 8 * 3600, until: stamp + 900 });
291        assert_eq!(when(&reminder(stamp + 600, stamp + 3600), &usual(Wait::Nothing), stamp), stamp + 600);
292        assert_eq!(when(&reminder(stamp - 60, stamp + 3600), &usual(Wait::Nothing), stamp), stamp + 1);
293        assert_eq!(when(&reminder(stamp - 60, stamp + 3600), &night, stamp), stamp + 900, "kept in the list given, at waking");
294        // Held during do-not-disturb (the matrix changed): asked again in a quarter of an hour.
295        let mut focus = usual(Wait::Nothing);
296        focus.now.dnd = true;
297        focus.attention.set(sioul_core::attention::Row::Own(sioul_core::attention::Kind::Before), sioul_core::attention::Column::Dnd, sioul_core::attention::Level::Later).unwrap();
298        assert_eq!(when(&reminder(stamp - 60, stamp + 3600), &focus, stamp), stamp + AGAIN);
299    }
300}