1use 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")]
28unsafe extern "C" {
30 fn sioul_android_set_wake(json: *const c_char);
32 fn sioul_android_wake_state() -> i32;
34 fn sioul_android_wake_settings(which: *const c_char);
36 fn sioul_android_wake_try(json: *const c_char) -> i32;
39}
40
41static GIVEN: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
43
44const LATER_MINUTES: u32 = 10;
46const LOOK_BEFORE_MINUTES: i64 = 10;
48const TRY_SECONDS: i64 = 10;
50const NOT_A_PHONE: i32 = -2;
52
53fn 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
64fn 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
81pub(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
104fn unknown() -> String {
106 serde_json::json!({ "unknown": true }).to_string()
107}
108
109fn 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 unsafe {
119 sioul_android_set_wake(text.as_ptr())
120 };
121 let _ = text;
122 *given = json;
123}
124
125pub(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
137fn 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#[unsafe(no_mangle)]
162pub unsafe extern "C" fn sioul_wake_next(zone: *const c_char, fetch: bool) -> *mut c_char {
163 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
169fn allowed() -> i32 {
172 #[cfg(target_os = "android")]
173 return unsafe { sioul_android_wake_state() };
175 #[cfg(not(target_os = "android"))]
176 7
177}
178
179pub(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
204pub(crate) fn open_settings(which: &str) {
208 let Ok(text) = CString::new(which) else { return };
209 #[cfg(target_os = "android")]
210 unsafe {
212 sioul_android_wake_settings(text.as_ptr())
213 };
214 let _ = text;
215}
216
217pub(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
226fn hand_try(json: &str) -> i32 {
228 let Ok(text) = CString::new(json) else { return -1 };
229 #[cfg(target_os = "android")]
230 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
239fn 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
261pub(crate) fn try_now() -> String {
265 tried(hand_try(&trial(&Zoned::now(), tr()).to_string()), tr()).to_string()
266}
267
268pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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}