1use 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")]
29unsafe extern "C" {
31 fn sioul_android_set_event_alarms(json: *const c_char);
33 fn sioul_android_event_note(json: *const c_char);
35 fn sioul_android_mail_note(json: *const c_char);
37 fn sioul_android_day_note(json: *const c_char);
39 fn sioul_android_take_reminder_opened(buffer: *mut c_char, size: i32) -> bool;
41}
42
43const AT_MOST: usize = 100;
45const LOOK_AGAIN: i64 = 24 * 3600;
47const LOOK_STEP: i64 = 6 * 3600;
49const AGAIN: i64 = 15 * 60;
51
52static GIVEN: Mutex<String> = Mutex::new(String::new());
54
55fn 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
69pub(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
80fn 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
101fn 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
113fn 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 unsafe {
123 sioul_android_set_event_alarms(text.as_ptr())
124 };
125 let _ = text;
126 *given = json;
127}
128
129pub(crate) fn schedule() {
132 if !cfg!(target_os = "android") {
133 return;
134 }
135 hand(coming(&Zoned::now()));
136}
137
138pub(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
147pub(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 unsafe {
163 sioul_android_event_note(text.as_ptr())
164 };
165 let _ = text;
166}
167
168#[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 unsafe {
179 sioul_android_mail_note(text.as_ptr())
180 };
181 let _ = text;
182}
183
184pub(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 unsafe {
195 sioul_android_day_note(text.as_ptr())
196 };
197 let _ = text;
198}
199
200fn 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
207fn 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
221fn step_after(stamp: i64, step: i64) -> i64 {
223 (stamp.div_euclid(step) + 1) * step
224}
225
226fn 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 let shown = reminders::claim(&dir, &r.key);
252 if shown {
253 show(r);
254 }
255 serde_json::json!({ "shown": shown })
256 }
257 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#[unsafe(no_mangle)]
276pub unsafe extern "C" fn sioul_event_decide(key: *const c_char) -> *mut c_char {
277 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#[unsafe(no_mangle)]
291pub unsafe extern "C" fn sioul_event_coming(zone: *const c_char) -> *mut c_char {
292 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 let _ = std::panic::catch_unwind(|| crate::calls::refresh(false));
305 crate::alarms::handed(answer)
306}
307
308pub(crate) fn opened() -> Option<(String, String)> {
312 #[cfg(target_os = "android")]
313 {
314 let mut buffer = vec![0u8; 4096];
315 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#[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 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 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 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 #[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 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 #[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}