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.NotificationChannel;
009import android.app.NotificationManager;
010import android.app.PendingIntent;
011import android.content.ActivityNotFoundException;
012import android.content.Context;
013import android.content.Intent;
014import android.content.SharedPreferences;
015import android.net.Uri;
016import android.os.Build;
017import android.os.UserManager;
018import android.provider.Settings;
019import android.util.Log;
020
021import org.json.JSONArray;
022import org.json.JSONException;
023import org.json.JSONObject;
024
025import java.time.Instant;
026import java.time.LocalDate;
027import java.time.LocalDateTime;
028import java.time.LocalTime;
029import java.time.ZoneId;
030import java.time.ZonedDateTime;
031import java.util.Locale;
032
033/**
034 * The alarm at waking (docs/android.md, "Waking"): it rings when the night
035 * ends, on the mornings ticked with the night in Sioul's Health settings.
036 * Rust hands the coming wakings, eight days ahead, whenever they change
037 * (crates/sioul-app/src/wake.rs, through android/main.cpp). Here they are
038 * kept, in the storage a phone reads before its first unlock (a phone that
039 * restarted in the night still rings), and the next is given to Android's
040 * alarm clock: setAlarmClock, exact, through Doze, the alarm shown in the
041 * status bar. At each ring, a little before each and as each night starts,
042 * after a restart, a change of time or of zone, Rust is asked again
043 * (WakeReceiver); when it cannot answer, the list kept goes on, then the
044 * usual week, repeated: never nothing. Apart from the doses: its own list,
045 * codes, channel and receiver.
046 */
047final class WakeAlarms
048{
049    static final String RING = "com.aurelienpierre.sioul.action.WAKE_RING";
050    static final String LOOK = "com.aurelienpierre.sioul.action.WAKE_LOOK";
051    static final String STOP = "com.aurelienpierre.sioul.action.WAKE_STOP";
052    static final String LATER = "com.aurelienpierre.sioul.action.WAKE_LATER";
053    /** "Try the alarm": rung as a waking, by its own action (told apart in `dumpsys alarm`). */
054    static final String TRY = "com.aurelienpierre.sioul.action.WAKE_TRY";
055    /** A ring's time (Unix milliseconds), on its intent. */
056    static final String AT = "at";
057    /** On a ring and its buttons: a try's; and a try's last ring, after its "10 min later" (no other). */
058    static final String TRIAL = "trial";
059    static final String LAST = "last";
060    /** Seconds before a try rings (wake.rs, TRY_SECONDS). */
061    static final int TRY_SECONDS = 10;
062    /** A try's answers (wake.rs): it rings; exact alarms, notifications or the full screen refused. */
063    static final int TRY_RINGS = 0;
064    static final int TRY_NO_EXACT = 1;
065    static final int TRY_NO_NOTIFICATIONS = 2;
066    static final int TRY_NO_SCREEN = 3;
067    /** Minutes "10 min later" puts the alarm off by (wake.rs, LATER_MINUTES). */
068    static final int LATER_MINUTES = 10;
069    /** The "Waking" channel: the ring's notification, and the one after "10 min later". */
070    static final String CHANNEL = "waking";
071
072    // Its alarms and buttons, by codes of their own (the doses' are their keys' hashes, to another receiver).
073    private static final int RING_CODE = 0x5711;
074    private static final int LOOK_CODE = 0x5712;
075    private static final int AGAIN_CODE = 0x5713;
076    private static final int SHOW_CODE = 0x5714;
077    static final int STOP_CODE = 0x5715;
078    static final int LATER_CODE = 0x5716;
079    static final int SCREEN_CODE = 0x5717;
080    // A try's own: its alarm, and its buttons (their extras say a try's: never a waking's buttons changed).
081    private static final int TRY_CODE = 0x5718;
082    static final int TRY_STOP_CODE = 0x5719;
083    static final int TRY_LATER_CODE = 0x571A;
084
085    private static final String KEPT = "sioul-waking";
086    private static final String LIST = "list";
087    /** The ring last given to Android, and the last that rang: a ring missed while the phone was off. */
088    private static final String ARMED = "armed";
089    private static final String RANG = "rang";
090    /** After "10 min later": when it rings again. */
091    private static final String AGAIN = "again";
092    /** A try's own keys: when it rings, and its words (before any list). Never the waking's. */
093    private static final String TRY_AT = "try_at";
094    private static final String TRY_WORDS = "try_words";
095    /** A ring missed while the phone was off rings as it starts again, when this late at most. */
096    private static final long MISSED_MS = 30 * 60 * 1000L;
097    /** A ring due within a minute is the one ringing now: never given again. */
098    private static final long DUE_MS = 60 * 1000L;
099
100    private WakeAlarms() {}
101
102    /** The coming wakings (wake.rs, `sioul_wake_next`), in the phone's `zone`; JSON, or {"unknown": true}. */
103    static native String nativeNext(String zone, boolean fetch);
104
105    // ---------------------------------------------------------------- the list
106
107    /**
108     * Rust's wakings (wake.rs): {rings, looks (Unix ms), week (Monday first,
109     * "07:00" or ""), zone, words}. Kept, then the next ring and look given to
110     * Android. Rust not knowing ({"unknown": true}: the night's file does not
111     * read) changes nothing kept. Any thread.
112     */
113    static synchronized void set(Context context, String json)
114    {
115        final JSONObject list;
116        try {
117            list = new JSONObject(json);
118        } catch (JSONException e) {
119            Log.e(DoseAlarms.TAG, "Waking: a list that does not read; the one kept stays. " + e);
120            return;
121        }
122        if (list.optBoolean("unknown")) {
123            Log.w(DoseAlarms.TAG, "Waking: Sioul cannot read the night now; the alarms kept stay.");
124            arm(context, "not known");
125            return;
126        }
127        kept(context).edit().putString(LIST, list.toString()).commit();
128        channel(context);
129        arm(context, "given");
130    }
131
132    /**
133     * The next ring and look given to Android's alarm clock, from the list
134     * kept: Android forgets them at a restart, a change of zone moves them.
135     * The list run out (Sioul not asked for eight days), the usual week. A
136     * ring after "10 min later" given again too. Never inexact: without
137     * "Alarms & reminders", nothing, and the Health settings say so. Any thread.
138     */
139    static synchronized void arm(Context context, String why)
140    {
141        JSONObject list = list(context);
142        long now = System.currentTimeMillis();
143        ZoneId here = ZoneId.systemDefault();
144        ZoneId made = zone(list.optString("zone"));
145        long ring = first(list.optJSONArray("rings"), made, here, now + DUE_MS);
146        boolean usual = ring == 0;
147        if (usual)
148            ring = usual(list.optJSONArray("week"), here, now + DUE_MS);
149        long look = first(list.optJSONArray("looks"), made, here, now);
150        long again = kept(context).getLong(AGAIN, 0);
151        if (!exact(context)) {
152            Log.w(DoseAlarms.TAG, "Waking: no \"Alarms & reminders\": no alarm given (" + why + ").");
153            return;
154        }
155        AlarmManager alarms = context.getSystemService(AlarmManager.class);
156        try {
157            if (ring > 0)
158                alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(ring, show(context)), ring(context, RING_CODE, ring));
159            else
160                alarms.cancel(ring(context, RING_CODE, 0));
161            if (again > now)
162                alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(again, show(context)), ring(context, AGAIN_CODE, again));
163            if (look > 0)
164                alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, look, look(context));
165            else
166                alarms.cancel(look(context));
167        } catch (SecurityException e) {
168            // "Alarms & reminders" taken back meanwhile.
169            Log.w(DoseAlarms.TAG, "Waking: Android refused the alarm (" + why + "). " + e);
170            return;
171        }
172        kept(context).edit().putLong(ARMED, ring).apply();
173        Log.i(DoseAlarms.TAG, "Waking: " + (ring > 0 ? "next at " + Instant.ofEpochMilli(ring) + (usual ? " (the usual week)" : "") : "none") + ", " + why + ".");
174    }
175
176    /**
177     * Rust asked for the coming wakings, then given them (WakeReceiver, on a
178     * thread of its own: Rust reads the sharing first, `fetch` the sync app
179     * given twenty seconds). Not before the phone's first unlock: Sioul's
180     * files cannot be read then, and the list kept rings. No answer: the
181     * list kept goes on.
182     */
183    static void ask(Context context, boolean fetch, String why)
184    {
185        if (!unlocked(context)) {
186            Log.i(DoseAlarms.TAG, "Waking: not unlocked since the phone started; the list kept rings (" + why + ").");
187            return;
188        }
189        String answer = null;
190        try {
191            DoseAlarms.load(context);
192            answer = nativeNext(ZoneId.systemDefault().getId(), fetch);
193        } catch (Throwable e) {
194            // Sioul's library that does not load, among others.
195            Log.e(DoseAlarms.TAG, "Waking: Sioul did not answer (" + why + "); the list kept goes on.", e);
196        }
197        if (answer != null && !answer.isEmpty())
198            set(context, answer);
199    }
200
201    /** A ring rang (or rings now): known, so that a restart does not ring it again. */
202    static synchronized void rang(Context context, long at)
203    {
204        SharedPreferences.Editor edit = kept(context).edit().putLong(RANG, at);
205        if (kept(context).getLong(AGAIN, 0) <= at + DUE_MS)
206            edit.remove(AGAIN);
207        edit.commit();
208    }
209
210    /** A ring the phone missed while it was off, no more than half an hour ago; 0 when none. */
211    static synchronized long missed(Context context)
212    {
213        SharedPreferences kept = kept(context);
214        long now = System.currentTimeMillis();
215        long rang = kept.getLong(RANG, 0);
216        for (long at : new long[] { kept.getLong(ARMED, 0), kept.getLong(AGAIN, 0) })
217            if (at > rang && at <= now && now - at < MISSED_MS)
218                return at;
219        return 0;
220    }
221
222    /** "10 min later": the alarm again in ten minutes, kept for a restart; when. */
223    static synchronized long later(Context context)
224    {
225        long again = System.currentTimeMillis() + LATER_MINUTES * 60_000L;
226        kept(context).edit().putLong(AGAIN, again).commit();
227        try {
228            if (exact(context))
229                context.getSystemService(AlarmManager.class).setAlarmClock(new AlarmManager.AlarmClockInfo(again, show(context)), ring(context, AGAIN_CODE, again));
230        } catch (SecurityException e) {
231            Log.w(DoseAlarms.TAG, "Waking: Android refused the alarm 10 min later. " + e);
232        }
233        return again;
234    }
235
236    /** Stop: nothing rings again before the next waking. */
237    static synchronized void stopped(Context context)
238    {
239        kept(context).edit().remove(AGAIN).commit();
240        context.getSystemService(AlarmManager.class).cancel(ring(context, AGAIN_CODE, 0));
241    }
242
243    // ---------------------------------------------------------------- a try
244
245    /**
246     * "Try the alarm" (Health's settings; wake.rs hands {at (Unix ms), words}):
247     * the alarm at `at`, ten seconds on, through the path a waking takes:
248     * Android's alarm clock, WakeReceiver, WakeRinger (the rising sound, in
249     * the foreground), WakeScreen over the lock screen. By its own action,
250     * code and keys: the next waking, its list and its "10 min later" are left
251     * as they are, and nothing is written of the night. When Android refuses
252     * exact alarms, notifications or the full screen, nothing is set: the
253     * answer says which (TRY_NO_…), for the setting to say why, rather than a
254     * try that rings unseen or not at all. Not given again after a restart.
255     */
256    static synchronized int tryNow(Context context, String json)
257    {
258        int refused = !exact(context) ? TRY_NO_EXACT : !notificationsAllowed(context) ? TRY_NO_NOTIFICATIONS : !fullScreen(context) ? TRY_NO_SCREEN : TRY_RINGS;
259        if (refused != TRY_RINGS) {
260            Log.w(DoseAlarms.TAG, "Waking: no try: " + (refused == TRY_NO_EXACT ? "no \"Alarms & reminders\"." : refused == TRY_NO_NOTIFICATIONS ? "notifications off." : "no full screen."));
261            return refused;
262        }
263        long now = System.currentTimeMillis();
264        long at = now + TRY_SECONDS * 1000L;
265        JSONObject words = null;
266        try {
267            JSONObject asked = new JSONObject(json);
268            long given = asked.optLong("at");
269            if (given > now && given < now + 60_000L)
270                at = given;
271            words = asked.optJSONObject("words");
272        } catch (JSONException e) {
273            // Ten seconds on, the words kept.
274        }
275        SharedPreferences.Editor edit = kept(context).edit().putLong(TRY_AT, at);
276        if (words != null)
277            edit.putString(TRY_WORDS, words.toString());
278        edit.commit();
279        // A try's "rings again" line, from a try before: no longer true.
280        context.getSystemService(NotificationManager.class).cancel(WakeRinger.TRY_AGAIN_NOTE);
281        channel(context);
282        try {
283            context.getSystemService(AlarmManager.class).setAlarmClock(new AlarmManager.AlarmClockInfo(at, show(context)), trial(context, at, false));
284        } catch (SecurityException e) {
285            Log.w(DoseAlarms.TAG, "Waking: no try: Android refused the alarm. " + e);
286            return TRY_NO_EXACT;
287        }
288        Log.i(DoseAlarms.TAG, "Waking: a try at " + Instant.ofEpochMilli(at) + "; the next waking left as it is.");
289        return TRY_RINGS;
290    }
291
292    /** A try's "10 min later": rung once more, as a try, its last time (without "10 min later"); when. */
293    static synchronized long tryLater(Context context)
294    {
295        long at = System.currentTimeMillis() + LATER_MINUTES * 60_000L;
296        kept(context).edit().putLong(TRY_AT, at).commit();
297        try {
298            if (exact(context))
299                context.getSystemService(AlarmManager.class).setAlarmClock(new AlarmManager.AlarmClockInfo(at, show(context)), trial(context, at, true));
300        } catch (SecurityException e) {
301            Log.w(DoseAlarms.TAG, "Waking: Android refused the try 10 min later. " + e);
302        }
303        Log.i(DoseAlarms.TAG, "Waking: the try again at " + Instant.ofEpochMilli(at) + ", its last time.");
304        return at;
305    }
306
307    /** A try over (Stop, or its last ring): its alarm, if one is left, taken back; nothing of the waking's. */
308    static synchronized void tryStopped(Context context)
309    {
310        kept(context).edit().remove(TRY_AT).commit();
311        context.getSystemService(AlarmManager.class).cancel(trial(context, 0, false));
312        Log.i(DoseAlarms.TAG, "Waking: the try is over; the next waking left as it is.");
313    }
314
315    /** A try's alarm: to WakeReceiver as a waking's, by its own action and code. */
316    private static PendingIntent trial(Context context, long at, boolean last)
317    {
318        Intent ring = new Intent(context, WakeReceiver.class).setAction(TRY).putExtra(AT, at).putExtra(TRIAL, true).putExtra(LAST, last);
319        return PendingIntent.getBroadcast(context, TRY_CODE, ring, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
320    }
321
322    /** The first of `times` after `after`, moved to the same time on the clock here; 0 when none. */
323    private static long first(JSONArray times, ZoneId made, ZoneId here, long after)
324    {
325        long first = 0;
326        for (int i = 0; times != null && i < times.length(); i++) {
327            long at = rezoned(times.optLong(i), made, here);
328            if (at > after && (first == 0 || at < first))
329                first = at;
330        }
331        return first;
332    }
333
334    /**
335     * A time made in another zone (the phone taken elsewhere, or Sioul's
336     * zone a few minutes behind a change), at the same time on the clock
337     * here: a waking at 07:00 stays at 07:00.
338     */
339    static long rezoned(long at, ZoneId made, ZoneId here)
340    {
341        if (at <= 0 || made == null || made.equals(here))
342            return at;
343        LocalDateTime clock = LocalDateTime.ofInstant(Instant.ofEpochMilli(at), made);
344        return clock.atZone(here).toInstant().toEpochMilli();
345    }
346
347    /**
348     * The next ring of the usual `week` (Monday first: "07:00", or "" when
349     * that morning does not ring) after `after`, here; 0 when none is ticked.
350     * What rings when Sioul cannot be asked: the usual mornings, repeated.
351     */
352    static long usual(JSONArray week, ZoneId here, long after)
353    {
354        if (week == null || week.length() != 7)
355            return 0;
356        LocalDate day = Instant.ofEpochMilli(after).atZone(here).toLocalDate();
357        for (int i = 0; i < 9; i++, day = day.plusDays(1)) {
358            String time = week.optString(day.getDayOfWeek().getValue() - 1);
359            if (time.isEmpty())
360                continue;
361            try {
362                // A time the change of hour skips: just after it, as Rust says it.
363                long at = ZonedDateTime.of(day, LocalTime.parse(time), here).toInstant().toEpochMilli();
364                if (at > after)
365                    return at;
366            } catch (RuntimeException e) {
367                Log.w(DoseAlarms.TAG, "Waking: a usual time that does not read: " + time);
368            }
369        }
370        return 0;
371    }
372
373    private static ZoneId zone(String id)
374    {
375        try {
376            return id == null || id.isEmpty() ? null : ZoneId.of(id);
377        } catch (RuntimeException e) {
378            return null;
379        }
380    }
381
382    /** A ring's alarm (the next waking, or the one after "10 min later"), to WakeReceiver, by no other app. */
383    private static PendingIntent ring(Context context, int code, long at)
384    {
385        Intent ring = new Intent(context, WakeReceiver.class).setAction(RING).putExtra(AT, at);
386        return PendingIntent.getBroadcast(context, code, ring, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
387    }
388
389    /** The question before a ring and as a night starts. */
390    private static PendingIntent look(Context context)
391    {
392        return PendingIntent.getBroadcast(context, LOOK_CODE, new Intent(context, WakeReceiver.class).setAction(LOOK),
393                                          PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
394    }
395
396    /** The alarm tapped in the status bar or on the lock screen: Sioul, as by its icon. */
397    private static PendingIntent show(Context context)
398    {
399        Intent sioul = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
400        if (sioul == null)
401            return null;
402        return PendingIntent.getActivity(context, SHOW_CODE, sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED),
403                                         PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
404    }
405
406    // ---------------------------------------------------------------- what Android allows
407
408    /** "Alarms &amp; reminders": Android 12 gives it at install and lets you take it back; 13 gives it for good (USE_EXACT_ALARM). */
409    static boolean exact(Context context)
410    {
411        return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || context.getSystemService(AlarmManager.class).canScheduleExactAlarms();
412    }
413
414    /** The screen lit over the lock screen as it rings: yours to refuse from Android 14. */
415    static boolean fullScreen(Context context)
416    {
417        return Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || context.getSystemService(NotificationManager.class).canUseFullScreenIntent();
418    }
419
420    /** Sioul's notifications shown, and the "Waking" channel not turned off: else the ring has no Stop to show. */
421    static boolean notificationsAllowed(Context context)
422    {
423        NotificationManager notifications = context.getSystemService(NotificationManager.class);
424        if (!notifications.areNotificationsEnabled())
425            return false;
426        NotificationChannel waking = notifications.getNotificationChannel(CHANNEL);
427        return waking == null || waking.getImportance() != NotificationManager.IMPORTANCE_NONE;
428    }
429
430    /** For Rust (android/main.cpp): 1 exact alarms, 2 the full screen, 4 notifications. */
431    static int state(Context context)
432    {
433        return (exact(context) ? 1 : 0) | (fullScreen(context) ? 2 : 0) | (notificationsAllowed(context) ? 4 : 0);
434    }
435
436    /** Android's page where one is allowed: "exact", "screen", "notifications"; Sioul's own page else. */
437    static void settings(Context context, String which)
438    {
439        String app = context.getPackageName();
440        Intent page;
441        if ("exact".equals(which) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
442            page = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:" + app));
443        else if ("screen".equals(which) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
444            page = new Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, Uri.parse("package:" + app));
445        else if ("notifications".equals(which))
446            page = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, app);
447        else
448            page = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app));
449        try {
450            context.startActivity(page.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
451        } catch (ActivityNotFoundException e) {
452            context.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
453        }
454    }
455
456    /** Whether the phone was unlocked since it started: Sioul's own files can be read. */
457    static boolean unlocked(Context context)
458    {
459        return context.getSystemService(UserManager.class).isUserUnlocked();
460    }
461
462    // ---------------------------------------------------------------- kept, and said
463
464    /**
465     * The "Waking" channel: high importance (over the lock screen, the
466     * screen lit). Without a sound of its own: WakeRinger plays it, rising,
467     * where a channel's would be at one volume and stop as the shade opens.
468     */
469    static void channel(Context context)
470    {
471        NotificationChannel waking = Channels.quiet(CHANNEL, word(context, "channel"), NotificationManager.IMPORTANCE_HIGH);
472        waking.setSound(null, null);
473        waking.enableVibration(false);
474        waking.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);
475        context.getSystemService(NotificationManager.class).createNotificationChannel(waking);
476    }
477
478    /**
479     * Kept where the phone reads it before its first unlock (device-protected
480     * storage): times and words only, nothing of what Sioul keeps.
481     */
482    private static SharedPreferences kept(Context context)
483    {
484        return context.createDeviceProtectedStorageContext().getSharedPreferences(KEPT, Context.MODE_PRIVATE);
485    }
486
487    private static JSONObject list(Context context)
488    {
489        try {
490            return new JSONObject(kept(context).getString(LIST, "{}"));
491        } catch (JSONException e) {
492            return new JSONObject();
493        }
494    }
495
496    private static final String[][] WORDS = {
497        { "channel", "Waking", "Réveil" },
498        { "title", "Waking", "Réveil" },
499        { "stop", "Stop", "Arrêter" },
500        { "later", "10 min later", "10 min plus tard" },
501        { "again", "Rings again at {time}", "Sonne de nouveau à {time}" },
502    };
503
504    /**
505     * The words said here, as Rust gave them with the wakings (or with a
506     * try), in the person's language (crates/sioul-core/locales); before
507     * either, these, by the phone's language.
508     */
509    static String word(Context context, String which)
510    {
511        JSONObject words = list(context).optJSONObject("words");
512        String said = words == null ? "" : words.optString(which);
513        if (said.isEmpty()) {
514            try {
515                said = new JSONObject(kept(context).getString(TRY_WORDS, "{}")).optString(which);
516            } catch (JSONException e) {
517                said = "";
518            }
519        }
520        if (!said.isEmpty())
521            return said;
522        boolean french = "fr".equals(Locale.getDefault().getLanguage());
523        for (String[] word : WORDS)
524            if (word[0].equals(which))
525                return word[french ? 2 : 1];
526        return which;
527    }
528}