001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.app.AlarmManager; 007import android.app.Notification; 008import android.app.NotificationManager; 009import android.app.PendingIntent; 010import android.content.Context; 011import android.content.Intent; 012import android.content.SharedPreferences; 013import android.graphics.drawable.Icon; 014import android.net.Uri; 015import android.os.UserManager; 016import android.util.Log; 017 018import org.json.JSONArray; 019import org.json.JSONException; 020import org.json.JSONObject; 021 022import java.time.ZoneId; 023import java.util.ArrayList; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.List; 027import java.util.Locale; 028import java.util.Map; 029import java.util.Set; 030 031/** 032 * Reminders before events while Sioul is not on the screen (docs/android.md, 033 * "Events"): Android freezes an app it does not show, so Rust hands the 034 * coming reminders of events ahead (crates/sioul-app/src/eventalarms.rs: 035 * their own alarms, Sioul's before them, the working day before), and each 036 * is given to Android's alarm clock, as the doses are (DoseAlarms). At its 037 * time EventReceiver asks Rust what to say: the event read again, sleep or a 038 * pause holding it or not, the time left said as it is then. Rust shows it 039 * through `show`: Sioul's reminders before an event and the working day 040 * before in the "Events" channel, at low importance (no sound, no banner); 041 * an alarm the event carries in "An event's alarms", at Android's default 042 * importance (its sound, no banner), or, while one of Sioul's modes lets 043 * them through, in its twin that passes do-not-disturb (PauseMode); each 044 * yours to change in Android's settings. A tap opens the event in Sioul 045 * (ReminderOpener). Rust silent, the words given with the list are shown 046 * instead: never nothing. Apart from the doses: its own list, alarms, 047 * channels and receiver. The list also holds when the end of work's notice 048 * and the night's may come (kind "day"): Rust decides those at their alarm 049 * and shows them through DayNotes, or nothing; no words are kept for them. 050 */ 051final class EventAlarms 052{ 053 static final String RING = "com.aurelienpierre.sioul.action.EVENT_REMINDER"; 054 static final String LOOK = "com.aurelienpierre.sioul.action.EVENT_LOOK"; 055 /** The "Events" channel: Sioul's reminders before an event, and the working day before; quiet. */ 056 static final String CHANNEL = "events"; 057 /** "An event's alarms": the alarms an event carries, with their sound. */ 058 static final String ALARMS = "event-alarms"; 059 060 private static final String SCHEME = "sioul-event"; 061 private static final String KEPT = "sioul-events"; 062 private static final String LIST = "list"; 063 private static final String WORDS = "words"; 064 private static final String OPENED = "opened"; 065 /** A reminder is known by its key (its tag) and this number. */ 066 private static final int NOTE = 2; 067 /** The question asked again once a day, nothing having rung meanwhile. */ 068 private static final int LOOK_CODE = 0x5801; 069 /** A reminder stays in the shade until an hour after its event began. */ 070 private static final long KEPT_AFTER_MS = 60 * 60 * 1000L; 071 072 private EventAlarms() {} 073 074 /** At a reminder's time (eventalarms.rs, `sioul_event_decide`): {"shown"}, {"again_at"}, {}; null when Rust failed. */ 075 static native String nativeDecide(String key); 076 /** The coming reminders, in the phone's `zone` (`sioul_event_coming`); "" when Rust failed. */ 077 static native String nativeComing(String zone); 078 079 // ---------------------------------------------------------------- the list 080 081 /** 082 * Rust's coming reminders: JSON {reminders: [{key, at (Unix ms), title, 083 * body}], look (Unix ms), words}. Their alarms replace those given 084 * before, as `merged` says; the list is kept for Android's restarts. Any thread. 085 */ 086 static synchronized void set(Context context, String json) 087 { 088 final JSONObject given; 089 try { 090 given = new JSONObject(json); 091 } catch (JSONException e) { 092 Log.e(DoseAlarms.TAG, "Events: the list does not read; the one kept stays. " + e); 093 return; 094 } 095 JSONArray coming = given.optJSONArray("reminders"); 096 if (coming == null) 097 coming = new JSONArray(); 098 Set<String> keys = new HashSet<>(); 099 for (int i = 0; i < coming.length(); i++) 100 keys.add(key(coming.optJSONObject(i))); 101 JSONArray before = list(context); 102 for (int i = 0; i < before.length(); i++) { 103 String key = key(before.optJSONObject(i)); 104 if (!key.isEmpty() && !keys.contains(key)) 105 cancel(context, key); 106 } 107 List<Arm> armed = new ArrayList<>(); 108 JSONArray list = merged(before, coming, System.currentTimeMillis(), armed); 109 JSONObject words = given.optJSONObject("words"); 110 SharedPreferences.Editor kept = kept(context).edit().putString(LIST, list.toString()); 111 if (words != null) 112 kept.putString(WORDS, words.toString()); 113 kept.commit(); 114 channel(context); 115 for (Arm arm : armed) 116 alarm(context, arm.key, arm.at); 117 look(context, given.optLong("look")); 118 Log.i(DoseAlarms.TAG, "Events: " + coming.length() + " reminders given, " + armed.size() + " alarms set, " + (DoseAlarms.exact(context) ? "on time." : "inexact (no \"Alarms & reminders\").")); 119 } 120 121 /** An alarm to set: its reminder's key, and when (Unix ms). */ 122 static final class Arm 123 { 124 final String key; 125 final long at; 126 127 Arm(String key, long at) 128 { 129 this.key = key; 130 this.at = at; 131 } 132 } 133 134 /** Whether a reminder of the list rang and was answered (`done`): kept while Rust lists it, never set again. */ 135 static final String DONE = "done"; 136 137 /** The time Rust gave a reminder last (Unix ms), kept apart from its "at", the time its alarm is set for, which `again` moves. */ 138 static final String GIVEN = "given"; 139 140 /** 141 * The list kept once Rust gives `coming` (`set`), and the alarms to set 142 * for it, put in `armed`: a reminder new to the list, at its time (one 143 * past rings at once: Rust says whether it still makes sense); one kept 144 * already, at a time Rust moved into the future, or moved earlier than 145 * it said before while it is not done, though that be past (the hours 146 * edited so that today's end of work was a few minutes ago; a hold that 147 * ended early): it rings at once; never one kept already again in the 148 * past at the time Rust said before: it rang already, or rings at the 149 * time `again` gave it, and keeps that time and whether it is done. So a 150 * list given again and again with a past reminder in it (the end of 151 * work's, while its ten minutes last) never rings it again and again: 152 * Rust's times for it stay put, or only grow ("a second from now"). Pure, 153 * checked on a JVM (android/jvm-checks/DayNotesCheck.java). 154 * 155 * @param kept the list kept until now 156 * @param coming the list Rust gives 157 * @param now the time now (Unix ms) 158 * @param armed the alarms to set, added to 159 * @return the list to keep 160 */ 161 static JSONArray merged(JSONArray kept, JSONArray coming, long now, List<Arm> armed) 162 { 163 Map<String, JSONObject> before = new HashMap<>(); 164 for (int i = 0; i < kept.length(); i++) { 165 JSONObject reminder = kept.optJSONObject(i); 166 if (!key(reminder).isEmpty()) 167 before.put(key(reminder), reminder); 168 } 169 JSONArray out = new JSONArray(); 170 for (int i = 0; i < coming.length(); i++) { 171 JSONObject reminder = coming.optJSONObject(i); 172 if (reminder == null) 173 continue; 174 String key = key(reminder); 175 long at = reminder.optLong("at"); 176 JSONObject old = before.get(key); 177 JSONObject listed = copied(reminder); 178 put(listed, GIVEN, at); 179 // Moved earlier than Rust said last; an entry an older Sioul kept says nothing of it. 180 boolean moved = old != null && old.has(GIVEN) && !old.optBoolean(DONE) && at < old.optLong(GIVEN); 181 if (old != null && at <= now && !moved) { 182 put(listed, "at", old.optLong("at")); 183 if (old.optBoolean(DONE)) 184 put(listed, DONE, true); 185 out.put(listed); 186 continue; 187 } 188 out.put(listed); 189 if (!key.isEmpty() && at > 0) 190 armed.add(new Arm(key, at)); 191 } 192 return out; 193 } 194 195 /** 196 * The list with `key` asked about again at `at` (`again`), set again, 197 * not done. Pure (DayNotesCheck). 198 * 199 * @param kept the list kept 200 * @param key the reminder's key 201 * @param at when (Unix ms) 202 * @return the list to keep 203 */ 204 static JSONArray asked(JSONArray kept, String key, long at) 205 { 206 JSONArray out = new JSONArray(); 207 for (int i = 0; i < kept.length(); i++) { 208 JSONObject reminder = copied(kept.optJSONObject(i)); 209 if (key.equals(key(reminder))) { 210 put(reminder, "at", at); 211 reminder.remove(DONE); 212 } 213 out.put(reminder); 214 } 215 return out; 216 } 217 218 /** 219 * The list with `key` done with (`done`): kept, marked, while Rust lists 220 * it, so that it is never set again; gone with the next list without it. 221 * Pure (DayNotesCheck). 222 * 223 * @param kept the list kept 224 * @param key the reminder's key 225 * @return the list to keep 226 */ 227 static JSONArray answered(JSONArray kept, String key) 228 { 229 JSONArray out = new JSONArray(); 230 for (int i = 0; i < kept.length(); i++) { 231 JSONObject reminder = copied(kept.optJSONObject(i)); 232 if (key.equals(key(reminder))) 233 put(reminder, DONE, true); 234 out.put(reminder); 235 } 236 return out; 237 } 238 239 /** A copy of `reminder`, or an empty one. */ 240 private static JSONObject copied(JSONObject reminder) 241 { 242 try { 243 return reminder == null ? new JSONObject() : new JSONObject(reminder.toString()); 244 } catch (JSONException e) { 245 return new JSONObject(); 246 } 247 } 248 249 /** The alarms given again from the list kept: Android forgets them when it restarts. One whose time went by rings at once: Rust says whether it still makes sense. Those done with stay unset. */ 250 static synchronized void restore(Context context, String why) 251 { 252 JSONArray coming = list(context); 253 schedule(context, coming); 254 Log.i(DoseAlarms.TAG, "Events: " + coming.length() + " reminders given again (" + why + ")."); 255 } 256 257 /** 258 * Rust asked for the coming reminders, then given them (EventReceiver, on 259 * a thread of its own): after a restart, an update, a change of time or 260 * of zone, once a day. Not before the phone's first unlock: Sioul's files 261 * cannot be read then. No answer: the list kept goes on. 262 */ 263 static void ask(Context context, String why) 264 { 265 if (!context.getSystemService(UserManager.class).isUserUnlocked()) 266 return; 267 String answer = null; 268 try { 269 DoseAlarms.load(context); 270 answer = nativeComing(ZoneId.systemDefault().getId()); 271 } catch (Throwable e) { 272 Log.e(DoseAlarms.TAG, "Events: Sioul did not answer (" + why + "); the list kept goes on.", e); 273 } 274 if (answer != null && !answer.isEmpty()) 275 set(context, answer); 276 } 277 278 /** A reminder asked about again at `at`: held now (you sleep), still worth telling then. */ 279 static synchronized void again(Context context, String key, long at) 280 { 281 kept(context).edit().putString(LIST, asked(list(context), key, at).toString()).commit(); 282 alarm(context, key, at); 283 } 284 285 /** A reminder done with (told, gone): marked, not to ring again, after a restart neither (`answered`). */ 286 static synchronized void done(Context context, String key) 287 { 288 kept(context).edit().putString(LIST, answered(list(context), key).toString()).commit(); 289 } 290 291 private static void schedule(Context context, JSONArray coming) 292 { 293 for (int i = 0; i < coming.length(); i++) { 294 JSONObject reminder = coming.optJSONObject(i); 295 String key = key(reminder); 296 long at = reminder == null ? 0 : reminder.optLong("at"); 297 if (!key.isEmpty() && at > 0 && !reminder.optBoolean(DONE)) 298 alarm(context, key, at); 299 } 300 } 301 302 /** 303 * A reminder's alarm, at its time even while the phone sleeps (Doze); 304 * without "Alarms & reminders", as close as Android allows: up to an hour 305 * late. Never Android's alarm clock: no alarm icon for an appointment. 306 */ 307 private static void alarm(Context context, String key, long at) 308 { 309 AlarmManager alarms = context.getSystemService(AlarmManager.class); 310 PendingIntent ring = PendingIntent.getBroadcast(context, key.hashCode(), ringIntent(context, key), 311 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 312 try { 313 if (DoseAlarms.exact(context)) { 314 alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, ring); 315 return; 316 } 317 } catch (SecurityException e) { 318 // Taken back between the question and the alarm. 319 } 320 alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, ring); 321 } 322 323 private static void cancel(Context context, String key) 324 { 325 PendingIntent ring = PendingIntent.getBroadcast(context, key.hashCode(), ringIntent(context, key), 326 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_NO_CREATE); 327 if (ring != null) { 328 context.getSystemService(AlarmManager.class).cancel(ring); 329 ring.cancel(); 330 } 331 } 332 333 /** The list asked for again at `at` (inexact: nothing hangs on its minute). */ 334 private static void look(Context context, long at) 335 { 336 PendingIntent look = PendingIntent.getBroadcast(context, LOOK_CODE, new Intent(context, EventReceiver.class).setAction(LOOK), 337 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 338 if (at > System.currentTimeMillis()) 339 context.getSystemService(AlarmManager.class).setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, look); 340 } 341 342 private static Intent ringIntent(Context context, String key) 343 { 344 return new Intent(context, EventReceiver.class).setAction(RING).setData(Uri.fromParts(SCHEME, key, null)); 345 } 346 347 /** The reminder an intent is about; "" when none. */ 348 static String key(Intent intent) 349 { 350 Uri address = intent == null ? null : intent.getData(); 351 if (address == null || !SCHEME.equals(address.getScheme())) 352 return ""; 353 String key = address.getSchemeSpecificPart(); 354 return key == null ? "" : key; 355 } 356 357 private static String key(JSONObject reminder) 358 { 359 return reminder == null ? "" : reminder.optString("key"); 360 } 361 362 /** The words given with a reminder in the list, shown when Rust cannot be asked; null when not kept. */ 363 static synchronized JSONObject kept(Context context, String key) 364 { 365 JSONArray coming = list(context); 366 for (int i = 0; i < coming.length(); i++) { 367 JSONObject reminder = coming.optJSONObject(i); 368 if (key.equals(key(reminder))) 369 return reminder; 370 } 371 return null; 372 } 373 374 private static void put(JSONObject object, String name, Object value) 375 { 376 try { 377 object.put(name, value); 378 } catch (JSONException e) { 379 // Only for a name that is null or a number that is not one. 380 } 381 } 382 383 // ---------------------------------------------------------------- kept 384 385 private static SharedPreferences kept(Context context) 386 { 387 return context.getSharedPreferences(KEPT, Context.MODE_PRIVATE); 388 } 389 390 private static JSONArray list(Context context) 391 { 392 try { 393 return new JSONArray(kept(context).getString(LIST, "[]")); 394 } catch (JSONException e) { 395 return new JSONArray(); 396 } 397 } 398 399 /** A tapped notification's thing, kept until Sioul takes it (ReminderOpener): "event" and its file, "porch". */ 400 static synchronized void opened(Context context, String kind, String key) 401 { 402 JSONObject thing = new JSONObject(); 403 put(thing, "kind", kind); 404 put(thing, "key", key == null ? "" : key); 405 kept(context).edit().putString(OPENED, thing.toString()).apply(); 406 } 407 408 /** What a tapped notification opens, once, as JSON {kind, key}; null when nothing (android/main.cpp, for Rust). */ 409 static synchronized String takeOpened(Context context) 410 { 411 SharedPreferences kept = kept(context); 412 String thing = kept.getString(OPENED, null); 413 if (thing != null) 414 kept.edit().remove(OPENED).apply(); 415 return thing; 416 } 417 418 // ---------------------------------------------------------------- the reminders 419 420 /** 421 * The channels: "Events" at low importance, without sound or banner, for 422 * Sioul's reminders before an event and the working day before; "An 423 * event's alarms" at Android's default importance (its sound, no banner), 424 * for the alarms an event carries. Yours to change. 425 */ 426 static void channel(Context context) 427 { 428 NotificationManager notifications = context.getSystemService(NotificationManager.class); 429 notifications.createNotificationChannel(Channels.quiet(CHANNEL, word(context, "channel"), NotificationManager.IMPORTANCE_LOW)); 430 notifications.createNotificationChannel(Channels.quiet(ALARMS, word(context, "alarms"), NotificationManager.IMPORTANCE_DEFAULT)); 431 } 432 433 /** 434 * Whether a reminder is an alarm its event carries: as Rust says it 435 * ("kind": "alarm", "before", "event"), else by its key (`alarm:…`, a 436 * list given by an older Sioul). Pure, checked on a JVM 437 * (android/jvm-checks/ModeCheck.java). 438 */ 439 static boolean isAlarm(String kind, String key) 440 { 441 if (kind != null && !kind.isEmpty()) 442 return "alarm".equals(kind); 443 return key != null && key.startsWith("alarm:"); 444 } 445 446 /** 447 * Rust's reminder (eventalarms.rs, `show`): JSON {key, kind, title, body, 448 * event (its file), until (Unix ms, when the event begins), words}. A 449 * tap, or Open, brings Sioul up on the event. Any thread. 450 */ 451 static void show(Context context, String json) 452 { 453 final JSONObject said; 454 try { 455 said = new JSONObject(json); 456 } catch (JSONException e) { 457 Log.e(DoseAlarms.TAG, "Events: a reminder that does not read. " + e); 458 return; 459 } 460 JSONObject words = said.optJSONObject("words"); 461 if (words != null) 462 kept(context).edit().putString(WORDS, words.toString()).apply(); 463 String key = said.optString("key"); 464 post(context, key, isAlarm(said.optString("kind"), key), said.optString("title"), said.optString("body"), said.optString("event"), said.optLong("until")); 465 // Told (from the window's minute, maybe): its alarm has nothing left to do. 466 cancel(context, key); 467 done(context, key); 468 } 469 470 /** 471 * The words given with the list, said when Rust cannot be asked at a 472 * reminder's time: never nothing. Not the end of work's notice nor the 473 * night's (kind "day"): said by Rust alone, or not at all, as on a 474 * computer (Rust's notice may not come: the day closed, a meeting, the 475 * notice told on another device). 476 */ 477 static void fallback(Context context, String key) 478 { 479 JSONObject reminder = kept(context, key); 480 if (reminder == null || isDay(reminder.optString("kind"))) 481 return; 482 post(context, key, isAlarm(reminder.optString("kind"), key), reminder.optString("title"), reminder.optString("body"), "", 0); 483 } 484 485 /** 486 * Whether an entry of the list is the end of work's notice or the 487 * night's (DayNotes), which have no words kept. Pure, checked on a JVM 488 * (android/jvm-checks/DayNotesCheck.java). 489 * 490 * @param kind the entry's kind, as Rust gave it 491 * @return whether it is one of the day's 492 */ 493 static boolean isDay(String kind) 494 { 495 return "day".equals(kind); 496 } 497 498 /** A reminder said for nothing (by the fallback) taken away. */ 499 static void dismiss(Context context, String key) 500 { 501 context.getSystemService(NotificationManager.class).cancel(key, NOTE); 502 } 503 504 /** 505 * A reminder in the shade: an alarm the event carries in "An event's 506 * alarms", or in its twin that passes do-not-disturb while every one of 507 * Sioul's modes on lets an event's alarms through (PauseMode); Sioul's 508 * own reminders in the quiet "Events". 509 */ 510 private static void post(Context context, String key, boolean alarm, String title, String body, String event, long until) 511 { 512 channel(context); 513 String channel = alarm ? PauseMode.alarmsChannelNow(context) : CHANNEL; 514 // Without its event's file (said from the words kept): Sioul as by its icon. 515 Intent sioul = new Intent(context, ReminderOpener.class).setData(Uri.fromParts(ReminderOpener.SCHEME, event.isEmpty() ? "app" : "event", null)) 516 .putExtra(ReminderOpener.KEY, event).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 517 PendingIntent open = PendingIntent.getActivity(context, key.hashCode(), sioul, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 518 Notification.Builder reminder = new Notification.Builder(context, channel) 519 .setSmallIcon(R.drawable.sioul_notification) 520 .setColor(context.getColor(R.color.sioul_green)) 521 .setContentTitle(title.isEmpty() ? "Sioul" : title) 522 .setContentText(body) 523 .setStyle(new Notification.BigTextStyle().bigText(body)) 524 .setCategory(Notification.CATEGORY_REMINDER) 525 .setContentIntent(open) 526 .setAutoCancel(true) 527 .setOnlyAlertOnce(true) 528 .addAction(new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.sioul_notification), word(context, "open"), open).build()); 529 long left = until - System.currentTimeMillis(); 530 if (until > 0 && left > -KEPT_AFTER_MS) 531 reminder.setTimeoutAfter(left + KEPT_AFTER_MS); 532 context.getSystemService(NotificationManager.class).notify(key, NOTE, reminder.build()); 533 } 534 535 // ---------------------------------------------------------------- the words said here 536 537 private static final String[][] FALLBACK_WORDS = { 538 { "channel", "Events", "Événements" }, 539 { "alarms", "An event's alarms", "Alarmes des événements" }, 540 { "alarms-pause", "An event's alarms during a pause", "Alarmes des événements pendant une pause" }, 541 { "mail", "New mail", "Nouveaux courriels" }, 542 { "mail-through", "New mail from people always let through", "Courriels des personnes qui passent toujours" }, 543 { "day", "End of the day", "Fin de journée" }, 544 { "open", "Open", "Ouvrir" }, 545 }; 546 547 /** 548 * The words said here, as Rust gave them with the list (in the person's 549 * language, crates/sioul-core/locales); before any list, these, by the 550 * phone's language. 551 */ 552 static String word(Context context, String which) 553 { 554 try { 555 String said = new JSONObject(kept(context).getString(WORDS, "{}")).optString(which); 556 if (!said.isEmpty()) 557 return said; 558 } catch (JSONException e) { 559 // The phone's own words below. 560 } 561 boolean french = "fr".equals(Locale.getDefault().getLanguage()); 562 for (String[] word : FALLBACK_WORDS) 563 if (word[0].equals(which)) 564 return word[french ? 2 : 1]; 565 return which; 566 } 567}