Skip to main content

sioul_app/
wake.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The alarm at waking, on a phone (docs/health.md, "The alarm at waking";
5//! docs/android.md, "Waking"): it rings when the night ends, on the mornings
6//! ticked with the night in the Health page's settings, each night as that
7//! day has it (`needs::Needs::alarm_of`). Android's alarm clock rings it
8//! (android/package/src/com/aurelienpierre/sioul/WakeAlarms.java): Sioul
9//! hands Java the coming wakings, eight days ahead, whenever they change, and
10//! Java asks again here at each ring, a little before each and as each night
11//! starts (another device may have changed it while Android kept Sioul
12//! frozen), after a restart, a change of time or of zone. Elsewhere nothing
13//! rings: the setting travels with the night, and the page says when the
14//! phone will ring. "Try the alarm" rings it ten seconds on, the same way,
15//! handed apart (`trial`): nothing written, the next waking left as it is.
16
17use crate::backend::{say, tr};
18use jiff::Zoned;
19use jiff::civil::Date;
20use jiff::tz::TimeZone;
21use sioul_core::health::Health;
22use sioul_core::i18n::Translator;
23use sioul_core::needs::{Days, Needs, WAKINGS_AHEAD_DAYS};
24use std::ffi::{CString, c_char};
25use std::path::Path;
26
27#[cfg(target_os = "android")]
28// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
29unsafe extern "C" {
30    /// The coming wakings handed to Java, which keeps them and arms the next (android/main.cpp).
31    fn sioul_android_set_wake(json: *const c_char);
32    /// What Android allows the alarm: 1 exact alarms, 2 the full screen, 4 notifications (android/main.cpp).
33    fn sioul_android_wake_state() -> i32;
34    /// Android's own page for one of them: "exact", "screen", "notifications" (android/main.cpp).
35    fn sioul_android_wake_settings(which: *const c_char);
36    /// "Try the alarm": rung as a waking, the next waking left as it is (android/main.cpp):
37    /// 0 it rings; 1 exact alarms refused, 2 notifications, 3 the full screen; -1 not asked.
38    fn sioul_android_wake_try(json: *const c_char) -> i32;
39}
40
41/// The wakings last handed to Java, so that they are handed again only when they change.
42static GIVEN: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
43
44/// Minutes "10 min later" puts the alarm off by (WakeAlarms.LATER_MINUTES).
45const LATER_MINUTES: u32 = 10;
46/// Java asks again this many minutes before each ring, and as its night starts.
47const LOOK_BEFORE_MINUTES: i64 = 10;
48/// A try rings this many seconds after it is asked for (WakeAlarms.TRY_SECONDS).
49const TRY_SECONDS: i64 = 10;
50/// What a desktop answers a try: it never rings.
51const NOT_A_PHONE: i32 = -2;
52
53/// The usual night and its mornings, from Health's file at `path`: none when
54/// the file is there but does not read, so that a file broken (or being
55/// written by hand) never cancels an alarm: Java then keeps what it has.
56fn read_needs(path: &Path) -> Option<Needs> {
57    match std::fs::read_to_string(path) {
58        Ok(text) => toml::from_str::<Health>(&text).ok().map(|health| health.needs),
59        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(Needs::default()),
60        Err(_) => None,
61    }
62}
63
64/// The words Java says, in the person's language, given ahead with the
65/// wakings: Sioul may not run when the alarm rings (before the phone's first
66/// unlock, above all). `{time}` is the time Java puts in.
67fn words(tr: &Translator) -> serde_json::Value {
68    let mut later = sioul_core::i18n::args();
69    later.set("minutes", LATER_MINUTES);
70    let mut again = sioul_core::i18n::args();
71    again.set("time", "{time}");
72    serde_json::json!({
73        "channel": tr.text("wake-channel", None),
74        "title": tr.text("wake-ringing", None),
75        "stop": tr.text("wake-stop", None),
76        "later": tr.text("wake-later", Some(&later)),
77        "again": tr.text("wake-again", Some(&again)),
78    })
79}
80
81/// What Java is handed: `rings`, the coming alarms (Unix milliseconds, eight
82/// days ahead); `looks`, when to ask again (each night's start, and ten
83/// minutes before each ring); `week`, the usual mornings (Monday first, the
84/// waking or "" when it does not ring), repeated by Java should Sioul not
85/// answer; `zone`, the one the times were made in; `words`.
86pub(crate) fn coming(needs: &Needs, days: &Days, now: &Zoned, tr: &Translator) -> serde_json::Value {
87    let stamp = now.timestamp().as_second();
88    let alarms = needs.wakings(now, days, WAKINGS_AHEAD_DAYS);
89    let rings: Vec<i64> = alarms.iter().map(|a| a.at * 1000).collect();
90    let mut looks: Vec<i64> = alarms.iter().flat_map(|a| [a.night_start, a.at - LOOK_BEFORE_MINUTES * 60]).filter(|at| *at > stamp).map(|at| at * 1000).collect();
91    looks.sort_unstable();
92    looks.dedup();
93    let usual = if needs.sleep_on { needs.sleep.wake.as_str() } else { "" };
94    let week: Vec<&str> = needs.sleep.alarm.iter().map(|on| if *on { usual } else { "" }).collect();
95    serde_json::json!({
96        "rings": rings,
97        "looks": looks,
98        "week": week,
99        "zone": now.time_zone().iana_name().unwrap_or_default(),
100        "words": words(tr),
101    })
102}
103
104/// Java's answer when the night's file does not read: it keeps what it has.
105fn unknown() -> String {
106    serde_json::json!({ "unknown": true }).to_string()
107}
108
109/// The wakings handed to Java when they changed since they were last.
110fn hand(json: String) {
111    let Ok(mut given) = GIVEN.lock() else { return };
112    if *given == json {
113        return;
114    }
115    let Ok(text) = CString::new(json.clone()) else { return };
116    #[cfg(target_os = "android")]
117    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
118    unsafe {
119        sioul_android_set_wake(text.as_ptr())
120    };
121    let _ = text;
122    *given = json;
123}
124
125/// The coming wakings handed to Android when they changed: at each minute
126/// of the window, when the night, a day's change or the mornings ticked are
127/// saved (here, or brought by the sharing, read at the next minute), and as
128/// Sioul is put away. Nothing on a desktop: it never rings.
129pub(crate) fn schedule() {
130    if !cfg!(target_os = "android") {
131        return;
132    }
133    let Some(needs) = read_needs(&Health::default_path()) else { return };
134    hand(coming(&needs, &crate::health::days(), &Zoned::now(), tr()).to_string());
135}
136
137/// Java's question (`WakeAlarms.nativeNext`): at a ring; before one and as
138/// a night starts (`fetch`: the sync app asked first to bring what your other
139/// devices changed, and given twenty seconds); after a restart, an update, a
140/// change of time or of zone. What the other devices wrote is read first;
141/// then the wakings as `coming` makes them, in `zone`, the phone's, as Java
142/// has it (Sioul's own may lag minutes behind a change of zone). Blocks: never
143/// on Android's main thread.
144fn next(zone: &str, fetch: bool) -> String {
145    let _ = crate::share::exchange_here(fetch);
146    let zone = TimeZone::get(zone).unwrap_or_else(|_| TimeZone::system());
147    let Some(needs) = read_needs(&Health::default_path()) else { return unknown() };
148    let json = coming(&needs, &crate::health::days(), &Zoned::now().with_time_zone(zone), tr()).to_string();
149    if let Ok(mut given) = GIVEN.lock() {
150        *given = json.clone();
151    }
152    json
153}
154
155/// `next`, for Java (android/main.cpp); a panic is answered as not known, so
156/// that Java keeps the wakings it has. Given back to `sioul_string_free`.
157///
158/// # Safety
159/// `zone` is null, or a zero-terminated text valid for the call.
160// SAFETY: no other symbol of the program has this name.
161#[unsafe(no_mangle)]
162pub unsafe extern "C" fn sioul_wake_next(zone: *const c_char, fetch: bool) -> *mut c_char {
163    // SAFETY: `zone` is null or a C string valid for the call, as this function's contract says.
164    let zone = unsafe { crate::alarms::key_of(zone) };
165    let answer = std::panic::catch_unwind(|| next(&zone, fetch)).unwrap_or_else(|_| unknown());
166    crate::alarms::handed(answer)
167}
168
169/// What Android allows the alarm, as bits: 1 exact alarms, 2 the full
170/// screen, 4 notifications. All of it elsewhere, where nothing rings.
171fn allowed() -> i32 {
172    #[cfg(target_os = "android")]
173    // SAFETY: android/main.cpp's, without arguments; it may be called from any thread.
174    return unsafe { sioul_android_wake_state() };
175    #[cfg(not(target_os = "android"))]
176    7
177}
178
179/// The option as the Health page's settings show it (`health::needs_page`):
180/// the weekdays' names and short names, Monday first; the next ring; on a
181/// phone, what Android refuses it.
182pub(crate) fn status(needs: &Needs) -> serde_json::Value {
183    let now = Zoned::now();
184    let monday = jiff::civil::date(2026, 10, 5);
185    let week: Vec<Date> = (0..7).filter_map(|i| monday.checked_add(jiff::Span::new().days(i)).ok()).collect();
186    let next = needs
187        .wakings(&now, &crate::health::days(), WAKINGS_AHEAD_DAYS)
188        .first()
189        .and_then(|a| jiff::Timestamp::from_second(a.at).ok())
190        .map(|at| say("wake-next", &[("when", tr().date(&at.to_zoned(now.time_zone().clone()), false))]))
191        .unwrap_or_default();
192    let allowed = allowed();
193    serde_json::json!({
194        "phone": cfg!(target_os = "android"),
195        "names": (1..=7).map(|d| tr().text(&format!("weekday-{d}"), None)).collect::<Vec<_>>(),
196        "short": week.iter().map(|d| tr().weekday_short(*d)).collect::<Vec<_>>(),
197        "next": next,
198        "exact": allowed & 1 != 0,
199        "screen": allowed & 2 != 0,
200        "notifications": allowed & 4 != 0,
201    })
202}
203
204/// Android's own page where the alarm is allowed: "exact" (Alarms &
205/// reminders), "screen" (full-screen notifications, Android 14 and later),
206/// "notifications". Nothing elsewhere.
207pub(crate) fn open_settings(which: &str) {
208    let Ok(text) = CString::new(which) else { return };
209    #[cfg(target_os = "android")]
210    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
211    unsafe {
212        sioul_android_wake_settings(text.as_ptr())
213    };
214    let _ = text;
215}
216
217/// What a try hands Java (`WakeAlarms.tryNow`): {at, words}, the time it
218/// rings (Unix milliseconds, ten seconds on) and the words it says. Nothing
219/// of the night, its list or its next ring: a try is handed apart from them
220/// (`sioul_android_wake_try`, not `sioul_android_set_wake`) and rung by its
221/// own action and code, so the next waking stays as it is.
222pub(crate) fn trial(now: &Zoned, tr: &Translator) -> serde_json::Value {
223    serde_json::json!({ "at": now.timestamp().as_millisecond() + TRY_SECONDS * 1000, "words": words(tr) })
224}
225
226/// A try handed to Java: its answer (`sioul_android_wake_try`); a desktop never rings.
227fn hand_try(json: &str) -> i32 {
228    let Ok(text) = CString::new(json) else { return -1 };
229    #[cfg(target_os = "android")]
230    // SAFETY: android/main.cpp's; `text` is a C string alive until the call returns, and it copies it.
231    return unsafe { sioul_android_wake_try(text.as_ptr()) };
232    #[cfg(not(target_os = "android"))]
233    {
234        let _ = text;
235        NOT_A_PHONE
236    }
237}
238
239/// Java's answer to a try, said: {rings, line, fix, button}: it rings in ten
240/// seconds; or what Android refuses (exact alarms, notifications, the full
241/// screen), with its page ("exact", "notifications", "screen") and the
242/// button's words, rather than a try that rings unseen or not at all.
243fn tried(answer: i32, tr: &Translator) -> serde_json::Value {
244    let (rings, line, fix) = match answer {
245        0 => (true, "wake-try-rings", ""),
246        1 => (false, "wake-exact-off", "exact"),
247        2 => (false, "wake-notifications-off", "notifications"),
248        3 => (false, "wake-try-screen-off", "screen"),
249        NOT_A_PHONE => (false, "wake-try-phone", ""),
250        _ => (false, "wake-try-failed", ""),
251    };
252    let button = match fix {
253        "exact" => tr.text("wake-allow-exact", None),
254        "notifications" => tr.text("wake-allow-notifications", None),
255        "screen" => tr.text("wake-allow-screen", None),
256        _ => String::new(),
257    };
258    serde_json::json!({ "rings": rings, "line": tr.text(line, None), "fix": fix, "button": button })
259}
260
261/// "Try the alarm" (the Health page's settings, on a phone): the alarm rung
262/// ten seconds on, through the path a waking takes, over the lock screen.
263/// Nothing written, the next waking left as it is. JSON, as `tried` says it.
264pub(crate) fn try_now() -> String {
265    tried(hand_try(&trial(&Zoned::now(), tr()).to_string()), tr()).to_string()
266}
267
268/// The alarm at the end of `night`'s night, for its row on the page: when
269/// it rings ("07:00"), and whether "No alarm" was asked; ("", false) when
270/// none would ring (no morning ticked, the night taken out).
271pub(crate) fn of_night(needs: &Needs, night: Date, zone: &TimeZone, days: &Days) -> (String, bool) {
272    needs.alarm_of(night, zone, days).and_then(|a| jiff::Timestamp::from_second(a.at).ok().map(|at| (at.to_zoned(zone.clone()).strftime("%H:%M").to_string(), a.skipped))).unwrap_or_default()
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use sioul_core::needs::Sleep;
279
280    fn ticked(alarm: [bool; 7]) -> Needs {
281        Needs { sleep_on: true, sleep: Sleep { alarm, ..Sleep::default() }, ..Needs::default() }
282    }
283
284    fn at(text: &str) -> i64 {
285        text.parse::<Zoned>().unwrap().timestamp().as_millisecond()
286    }
287
288    #[test]
289    fn what_java_is_handed() {
290        // Sunday 4 October 2026, 22:30 in Paris, the night begun at 22:00; Monday to Friday mornings.
291        let now: Zoned = "2026-10-04T22:30[Europe/Paris]".parse().unwrap();
292        let needs = ticked([true, true, true, true, true, false, false]);
293        let english = Translator::new("en");
294        let handed = coming(&needs, &Days::default(), &now, &english);
295        let rings: Vec<i64> = serde_json::from_value(handed["rings"].clone()).unwrap();
296        assert_eq!(rings.len(), 6, "Monday to Friday, and Monday 12, eight days on: {handed}");
297        assert_eq!(rings[0], at("2026-10-05T07:00[Europe/Paris]"));
298        assert_eq!(rings[4], at("2026-10-09T07:00[Europe/Paris]"));
299        assert_eq!(rings[5], at("2026-10-12T07:00[Europe/Paris]"));
300        // Asked again ten minutes before each ring, and as each night starts (tonight's has).
301        let looks: Vec<i64> = serde_json::from_value(handed["looks"].clone()).unwrap();
302        assert_eq!(looks[0], at("2026-10-05T06:50[Europe/Paris]"));
303        assert_eq!(looks[1], at("2026-10-05T22:00[Europe/Paris]"));
304        assert_eq!(looks.len(), 11, "{looks:?}");
305        assert!(looks.windows(2).all(|w| w[0] < w[1]));
306        // The usual week, for when Sioul cannot answer; the zone; the words.
307        assert_eq!(handed["week"], serde_json::json!(["07:00", "07:00", "07:00", "07:00", "07:00", "", ""]));
308        assert_eq!(handed["zone"], "Europe/Paris");
309        assert_eq!(handed["words"]["stop"], "Stop");
310        assert_eq!(handed["words"]["later"], "10 min later");
311        assert_eq!(handed["words"]["again"], "Rings again at {time}");
312        assert_eq!(handed["words"]["channel"], "Waking");
313        // In French, with its typography.
314        let french = coming(&needs, &Days::default(), &now, &Translator::new("fr"));
315        assert_eq!(french["words"]["stop"], "Arrêter");
316        assert_eq!(french["words"]["later"], "10 min plus tard");
317        assert_eq!(french["words"]["again"], "Sonne de nouveau à {time}");
318        // No night set, or none ticked: nothing rings, nothing to repeat.
319        for off in [Needs { sleep_on: false, ..needs.clone() }, ticked([false; 7])] {
320            let handed = coming(&off, &Days::default(), &now, &english);
321            assert_eq!(handed["rings"], serde_json::json!([]));
322            assert_eq!(handed["looks"], serde_json::json!([]));
323            assert!(handed["week"].as_array().unwrap().iter().all(|w| w == ""));
324        }
325        // A day's own waking, and a night with no alarm, follow.
326        let mut days = Days::default();
327        days.change(jiff::civil::date(2026, 10, 5), "sleep", |b| b.wake = "08:30".into());
328        days.change(jiff::civil::date(2026, 10, 6), "sleep", |b| b.no_alarm = true);
329        let rings: Vec<i64> = serde_json::from_value(coming(&needs, &days, &now, &english)["rings"].clone()).unwrap();
330        assert_eq!(rings[..3], [at("2026-10-05T07:00[Europe/Paris]"), at("2026-10-06T08:30[Europe/Paris]"), at("2026-10-08T07:00[Europe/Paris]")]);
331    }
332
333    #[test]
334    fn a_file_that_does_not_read_cancels_nothing() {
335        let dir = std::env::temp_dir().join(format!("sioul-wake-{}", std::process::id()));
336        let _ = std::fs::remove_dir_all(&dir);
337        std::fs::create_dir_all(&dir).unwrap();
338        let path = dir.join("health.toml");
339        // None yet: no night, nothing rings.
340        assert_eq!(read_needs(&path), Some(Needs::default()));
341        std::fs::write(&path, "[needs]\nsleep_on = true\n[needs.sleep]\nbed = \"23:00\"\nwake = \"06:45\"\nalarm = [true, false, false, false, false, false, true]\n").unwrap();
342        let needs = read_needs(&path).unwrap();
343        assert_eq!((needs.sleep.wake.as_str(), needs.sleep.alarm), ("06:45", [true, false, false, false, false, false, true]));
344        // Broken (by hand, or half there): not known, and Java keeps its wakings.
345        std::fs::write(&path, "[needs\nsleep_on = tru").unwrap();
346        assert_eq!(read_needs(&path), None);
347        assert_eq!(unknown(), r#"{"unknown":true}"#);
348        let _ = std::fs::remove_dir_all(&dir);
349    }
350
351    #[test]
352    fn a_try_hands_java_its_time_and_words_only() {
353        let now: Zoned = "2026-10-06T21:15:30[Europe/Paris]".parse().unwrap();
354        let english = Translator::new("en");
355        let handed = trial(&now, &english);
356        // Ten seconds on, in Unix milliseconds; the words a waking says, in the person's language.
357        assert_eq!(handed["at"], now.timestamp().as_millisecond() + 10_000);
358        assert_eq!(handed["at"], "2026-10-06T21:15:40[Europe/Paris]".parse::<Zoned>().unwrap().timestamp().as_millisecond());
359        assert_eq!(handed["words"], words(&english));
360        assert_eq!(trial(&now, &Translator::new("fr"))["words"]["stop"], "Arrêter");
361        // Nothing of the night: no rings, looks, week or zone that could stand for the wakings' list.
362        let keys: Vec<&String> = handed.as_object().unwrap().keys().collect();
363        assert_eq!(keys, ["at", "words"]);
364    }
365
366    #[test]
367    fn a_try_leaves_the_next_waking_as_it_is() {
368        // The wakings handed (Monday to Friday at 07:00), then a try: the next ring, the list and
369        // what was handed are the same after it; a desktop rings nothing and says so.
370        let now: Zoned = "2026-10-06T21:15[Europe/Paris]".parse().unwrap();
371        let needs = ticked([true, true, true, true, true, false, false]);
372        let english = Translator::new("en");
373        let before = coming(&needs, &Days::default(), &now, &english);
374        let given = before.to_string();
375        *GIVEN.lock().unwrap() = given.clone();
376        let answer = hand_try(&trial(&now, &english).to_string());
377        assert_eq!(answer, NOT_A_PHONE);
378        assert_eq!(*GIVEN.lock().unwrap(), given, "the wakings handed, untouched");
379        assert_eq!(coming(&needs, &Days::default(), &now, &english), before);
380        assert_eq!(before["rings"][0], "2026-10-07T07:00[Europe/Paris]".parse::<Zoned>().unwrap().timestamp().as_millisecond());
381        assert_eq!(tried(answer, &english)["line"], "The alarm rings on a phone only.");
382    }
383
384    #[test]
385    fn a_try_refused_says_why() {
386        let english = Translator::new("en");
387        let said = |answer: i32| tried(answer, &english);
388        assert_eq!(said(0)["rings"], true);
389        assert_eq!(said(0)["line"], "Rings in about ten seconds, as a waking would; nothing else changes.");
390        assert_eq!(said(0)["fix"], "");
391        // Refused: never rings unseen, says why, and which page of Android's allows it.
392        for (answer, fix, button) in [(1, "exact", "Allow alarms…"), (2, "notifications", "Turn them on…"), (3, "screen", "Allow the full screen…")] {
393            let said = said(answer);
394            assert_eq!((said["rings"].as_bool(), said["fix"].as_str(), said["button"].as_str()), (Some(false), Some(fix), Some(button)), "{said}");
395            assert!(!said["line"].as_str().unwrap().is_empty());
396        }
397        assert_eq!(said(1)["line"], english.text("wake-exact-off", None));
398        assert_eq!(said(-1)["line"], "Android did not answer: nothing was set. Try again in a moment.");
399        assert_eq!(tried(3, &Translator::new("fr"))["button"], "Autoriser le plein écran…");
400    }
401
402    #[test]
403    fn the_night_s_row_says_its_alarm() {
404        let needs = ticked([true, true, true, true, true, false, false]);
405        let zone = TimeZone::get("Europe/Paris").unwrap();
406        let mut days = Days::default();
407        // Monday's night ends Tuesday at 07:00; Friday's, a Saturday: none.
408        assert_eq!(of_night(&needs, jiff::civil::date(2026, 10, 5), &zone, &days), ("07:00".to_string(), false));
409        assert_eq!(of_night(&needs, jiff::civil::date(2026, 10, 9), &zone, &days), (String::new(), false));
410        days.change(jiff::civil::date(2026, 10, 5), "sleep", |b| b.no_alarm = true);
411        assert_eq!(of_night(&needs, jiff::civil::date(2026, 10, 5), &zone, &days), ("07:00".to_string(), true));
412    }
413}