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 &amp; 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}