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.ComponentName;
012import android.content.Context;
013import android.content.Intent;
014import android.content.SharedPreferences;
015import android.content.pm.ActivityInfo;
016import android.content.pm.PackageManager;
017import android.graphics.drawable.Icon;
018import android.net.Uri;
019import android.os.Build;
020import android.util.Log;
021
022import org.json.JSONArray;
023import org.json.JSONException;
024import org.json.JSONObject;
025
026import java.util.HashSet;
027import java.util.Locale;
028import java.util.Set;
029
030/**
031 * Doses reminded while Sioul is not on the screen (docs/android.md, "Doses
032 * while Sioul is away"). Android freezes an app it does not show: each coming
033 * dose is given to Android's alarm clock ahead, and at its time DoseReceiver
034 * asks Rust's side what to say (crates/sioul-app/src/alarms.rs), which reads
035 * first what your other devices marked. Here: the alarms, their list kept for
036 * Android's restarts, the reminders, the dose of a tapped one, and Sioul's
037 * library, loaded without its window when Android starts Sioul for an alarm
038 * alone (android/main.cpp).
039 */
040final class DoseAlarms
041{
042    static final String TAG = "sioul";
043    static final String ALARM = "com.aurelienpierre.sioul.action.DOSE_ALARM";
044    static final String TAKEN = "com.aurelienpierre.sioul.action.DOSE_TAKEN";
045    /** The dose as the list names it: "Levothyroxine · 75 µg". */
046    static final String NAME = "name";
047    /** The reminder's title and text, as Rust said them. */
048    static final String TITLE = "title";
049    static final String BODY = "body";
050
051    private static final String SCHEME = "sioul-dose";
052    private static final String CHANNEL = "doses";
053    private static final String KEPT = "sioul-doses";
054    private static final String LIST = "alarms";
055    private static final String OPENED = "opened";
056    /** A reminder is known by its dose's key (its tag) and this number. */
057    private static final int REMINDER = 1;
058    /** "Taken at 08:03" stays half a day in the shade. */
059    private static final long CONFIRMED_MS = 12 * 60 * 60 * 1000L;
060
061    private static boolean loaded;
062
063    private DoseAlarms() {}
064
065    // ---------------------------------------------------------------- Sioul's library
066
067    static native void nativeInit(Context context);
068    static native String nativeDecide(String key);
069    static native String nativeTaken(String key);
070
071    /**
072     * Sioul's library, loaded and started once per process. Android may start
073     * Sioul for an alarm alone, without its window: then without Qt's Java
074     * side, which Qt's loader starts when the window opens. The library alone
075     * is loaded (Qt's libraries come as its own), by its name in the manifest
076     * and the processor, as Qt names it: libsioul_arm64-v8a.so.
077     */
078    static synchronized void load(Context context)
079    {
080        if (loaded)
081            return;
082        String name = "sioul";
083        try {
084            PackageManager packages = context.getPackageManager();
085            Intent launcher = packages.getLaunchIntentForPackage(context.getPackageName());
086            ComponentName window = launcher == null ? null : launcher.getComponent();
087            ActivityInfo info = window == null ? null : packages.getActivityInfo(window, PackageManager.GET_META_DATA);
088            if (info != null && info.metaData != null)
089                name = info.metaData.getString("android.app.lib_name", name);
090        } catch (PackageManager.NameNotFoundException e) {
091            Log.w(TAG, "Doses: the library's name is not in the manifest, \"sioul\" taken.");
092        }
093        UnsatisfiedLinkError first = null;
094        for (String abi : Build.SUPPORTED_ABIS) {
095            try {
096                System.loadLibrary(name + "_" + abi);
097                nativeInit(context.getApplicationContext());
098                loaded = true;
099                return;
100            } catch (UnsatisfiedLinkError e) {
101                if (first == null)
102                    first = e;
103            }
104        }
105        throw first != null ? first : new UnsatisfiedLinkError("Doses: no library " + name);
106    }
107
108    // ---------------------------------------------------------------- the alarms
109
110    /**
111     * Rust's coming doses (android/main.cpp): JSON [{key, at (Unix
112     * milliseconds), title}]. Their alarms replace those given before, and
113     * the list is kept for Android's restarts. Any thread.
114     */
115    static synchronized void set(Context context, String json)
116    {
117        final JSONArray doses;
118        try {
119            doses = new JSONArray(json);
120        } catch (JSONException e) {
121            Log.e(TAG, "Doses: the list of alarms does not read; those given before stay. " + e);
122            return;
123        }
124        Set<String> coming = new HashSet<>();
125        for (int i = 0; i < doses.length(); i++)
126            coming.add(key(doses.optJSONObject(i)));
127        JSONArray before = list(context);
128        for (int i = 0; i < before.length(); i++) {
129            String key = key(before.optJSONObject(i));
130            if (!key.isEmpty() && !coming.contains(key))
131                cancel(context, key);
132        }
133        schedule(context, doses);
134        keep(context, doses);
135        channel(context);
136        Log.i(TAG, "Doses: " + doses.length() + " alarms given, " + (exact(context) ? "on time." : "inexact (no \"Alarms & reminders\")."));
137    }
138
139    /**
140     * The alarms given again from the list kept: Android forgets them when it
141     * restarts (DoseBootReceiver). A dose whose time went by rings at once:
142     * Rust says whether it is still to remind.
143     */
144    static synchronized void restore(Context context, String why)
145    {
146        JSONArray doses = list(context);
147        schedule(context, doses);
148        Log.i(TAG, "Doses: " + doses.length() + " alarms given again (" + why + ").");
149    }
150
151    /** A dose asked about again at `at`, Rust waiting for news from your other devices. */
152    static synchronized void again(Context context, String key, long at, String name)
153    {
154        alarm(context, key, at, name);
155        JSONArray doses = list(context);
156        boolean found = false;
157        for (int i = 0; i < doses.length(); i++) {
158            JSONObject dose = doses.optJSONObject(i);
159            if (key.equals(key(dose))) {
160                put(dose, "at", at);
161                found = true;
162            }
163        }
164        if (!found) {
165            JSONObject dose = new JSONObject();
166            put(dose, "key", key);
167            put(dose, "at", at);
168            put(dose, "title", name);
169            doses.put(dose);
170        }
171        keep(context, doses);
172    }
173
174    /** A dose reminded: out of the list, not to ring again after a restart. */
175    static synchronized void reminded(Context context, String key)
176    {
177        JSONArray doses = list(context);
178        JSONArray left = new JSONArray();
179        for (int i = 0; i < doses.length(); i++)
180            if (!key.equals(key(doses.optJSONObject(i))))
181                left.put(doses.opt(i));
182        keep(context, left);
183    }
184
185    /**
186     * Whether the alarms ring on time: "Alarms &amp; reminders" allowed, which
187     * Android 12 lets you take back and gives from Android 13 (USE_EXACT_ALARM).
188     */
189    static boolean exact(Context context)
190    {
191        return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || context.getSystemService(AlarmManager.class).canScheduleExactAlarms();
192    }
193
194    private static void schedule(Context context, JSONArray doses)
195    {
196        for (int i = 0; i < doses.length(); i++) {
197            JSONObject dose = doses.optJSONObject(i);
198            String key = key(dose);
199            long at = dose == null ? 0 : dose.optLong("at");
200            if (!key.isEmpty() && at > 0)
201                alarm(context, key, at, dose.optString("title"));
202        }
203    }
204
205    /**
206     * A dose's alarm, at its time even while the phone sleeps (Doze). Not
207     * Android's alarm clock (setAlarmClock): it would show an alarm in the
208     * status bar for good, a dose always coming within two days, and put the
209     * doses before your own alarm on the lock screen. Without "Alarms &amp;
210     * reminders", as close as Android allows: up to an hour late.
211     */
212    private static void alarm(Context context, String key, long at, String name)
213    {
214        AlarmManager alarms = context.getSystemService(AlarmManager.class);
215        PendingIntent ring = PendingIntent.getBroadcast(context, key.hashCode(), alarmIntent(context, key).putExtra(NAME, name),
216                                                        PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
217        try {
218            if (exact(context)) {
219                alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, ring);
220                return;
221            }
222        } catch (SecurityException e) {
223            // Taken back between the question and the alarm.
224        }
225        alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, ring);
226    }
227
228    private static void cancel(Context context, String key)
229    {
230        PendingIntent ring = PendingIntent.getBroadcast(context, key.hashCode(), alarmIntent(context, key),
231                                                        PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_NO_CREATE);
232        if (ring != null) {
233            context.getSystemService(AlarmManager.class).cancel(ring);
234            ring.cancel();
235        }
236    }
237
238    private static Intent alarmIntent(Context context, String key)
239    {
240        return new Intent(context, DoseReceiver.class).setAction(ALARM).setData(address(key));
241    }
242
243    /** A dose's intents differ by their address, its key: one alarm per dose, whatever the keys' hashes. */
244    static Uri address(String key)
245    {
246        return Uri.fromParts(SCHEME, key, null);
247    }
248
249    /** The dose an intent is about; "" when none. */
250    static String key(Intent intent)
251    {
252        Uri address = intent == null ? null : intent.getData();
253        if (address == null || !SCHEME.equals(address.getScheme()))
254            return "";
255        String key = address.getSchemeSpecificPart();
256        return key == null ? "" : key;
257    }
258
259    private static String key(JSONObject dose)
260    {
261        return dose == null ? "" : dose.optString("key");
262    }
263
264    private static void put(JSONObject dose, String name, Object value)
265    {
266        try {
267            dose.put(name, value);
268        } catch (JSONException e) {
269            // Only for a name that is null or a number that is not one.
270        }
271    }
272
273    // ---------------------------------------------------------------- the list kept
274
275    private static SharedPreferences kept(Context context)
276    {
277        return context.getSharedPreferences(KEPT, Context.MODE_PRIVATE);
278    }
279
280    private static JSONArray list(Context context)
281    {
282        try {
283            return new JSONArray(kept(context).getString(LIST, "[]"));
284        } catch (JSONException e) {
285            return new JSONArray();
286        }
287    }
288
289    private static void keep(Context context, JSONArray doses)
290    {
291        kept(context).edit().putString(LIST, doses.toString()).commit();
292    }
293
294    /** A tapped reminder's dose, kept until Sioul takes it (DoseOpener). */
295    static synchronized void opened(Context context, String key)
296    {
297        kept(context).edit().putString(OPENED, key).apply();
298    }
299
300    /** The dose of a tapped reminder, once; null when there is none (android/main.cpp, for Rust). */
301    static synchronized String takeOpened(Context context)
302    {
303        SharedPreferences kept = kept(context);
304        String key = kept.getString(OPENED, null);
305        if (key != null) {
306            kept.edit().remove(OPENED).apply();
307            Log.i(TAG, "Doses: a tapped reminder's dose handed to Sioul.");
308        }
309        return key;
310    }
311
312    // ---------------------------------------------------------------- the reminders
313
314    /**
315     * The "Doses" channel: Android's default importance (its sound, no banner
316     * over what you do), yours to change in Android's settings.
317     */
318    static void channel(Context context)
319    {
320        context.getSystemService(NotificationManager.class).createNotificationChannel(
321            Channels.quiet(CHANNEL, "Doses", NotificationManager.IMPORTANCE_DEFAULT));
322    }
323
324    /** Whether reminders can show: Sioul's notifications on (from Android 13, your yes), the "Doses" channel too. */
325    static boolean notificationsAllowed(Context context)
326    {
327        NotificationManager notifications = context.getSystemService(NotificationManager.class);
328        if (!notifications.areNotificationsEnabled())
329            return false;
330        NotificationChannel doses = notifications.getNotificationChannel(CHANNEL);
331        return doses == null || doses.getImportance() != NotificationManager.IMPORTANCE_NONE;
332    }
333
334    /** Rust's reminder, as Rust said it, with "Taken"; a tap opens Sioul on the dose's question. */
335    static void remind(Context context, String key, String title, String body, String name, String taken)
336    {
337        Intent press = new Intent(context, DoseReceiver.class).setAction(TAKEN).setData(address(key))
338                           .putExtra(NAME, name).putExtra(TITLE, title).putExtra(BODY, body);
339        PendingIntent button = PendingIntent.getBroadcast(context, key.hashCode(), press,
340                                                          PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
341        Notification.Action action = new Notification.Action.Builder(
342            Icon.createWithResource(context, R.drawable.sioul_notification),
343            taken == null || taken.isEmpty() ? words(TAKEN_WORD) : taken, button).build();
344        show(context, key, reminder(context, key, title, body).addAction(action));
345    }
346
347    /** "Taken" recorded: the reminder becomes a quiet line, "Taken at 08:03", for half a day. */
348    static void confirm(Context context, String key, String title, String line)
349    {
350        show(context, key, reminder(context, key, title, line).setTimeoutAfter(CONFIRMED_MS));
351    }
352
353    /**
354     * "Taken" more than half an hour late: when, asked in Sioul, which a tap
355     * opens. Android opens no window from a button's broadcast (Android 12).
356     */
357    static void askWhen(Context context, String key, String title, String line)
358    {
359        show(context, key, reminder(context, key, title, line == null || line.isEmpty() ? words(LATE_WORDS) : line));
360    }
361
362    /**
363     * No answer from Rust, or none in time: never silence, never a claim. A
364     * dose to check in Sioul, as Rust says it then ("dose-alarm-fallback").
365     */
366    static void fallback(Context context, String key)
367    {
368        show(context, key, reminder(context, key, "Sioul", words(FALLBACK_WORDS)));
369    }
370
371    /** A dose's reminder taken away: marked meanwhile, or said for nothing. */
372    static void dismiss(Context context, String key)
373    {
374        context.getSystemService(NotificationManager.class).cancel(key, REMINDER);
375    }
376
377    private static Notification.Builder reminder(Context context, String key, String title, String text)
378    {
379        // Sioul's window opened by DoseOpener, never with the dose in its own intent.
380        PendingIntent open = PendingIntent.getActivity(context, key.hashCode(),
381                                                       new Intent(context, DoseOpener.class).setData(address(key)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
382                                                       PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
383        // During a pause that lets doses through, their own channel, which
384        // passes the pause's do-not-disturb (PauseMode); "Doses" otherwise.
385        return new Notification.Builder(context, PauseMode.dosesChannelNow(context))
386            .setSmallIcon(R.drawable.sioul_notification)
387            .setColor(context.getColor(R.color.sioul_green))
388            .setContentTitle(title)
389            .setContentText(text)
390            .setStyle(new Notification.BigTextStyle().bigText(text))
391            .setCategory(Notification.CATEGORY_REMINDER)
392            .setContentIntent(open)
393            .setAutoCancel(true)
394            // Replaced (by "Taken", by Rust's answer after the fallback): no second sound.
395            .setOnlyAlertOnce(true);
396    }
397
398    private static void show(Context context, String key, Notification.Builder reminder)
399    {
400        channel(context);
401        context.getSystemService(NotificationManager.class).notify(key, REMINDER, reminder.build());
402    }
403
404    // ---------------------------------------------------------------- the words said here
405
406    private static final int TAKEN_WORD = 0;
407    private static final int LATE_WORDS = 1;
408    private static final int FALLBACK_WORDS = 2;
409    private static final String[][] WORDS = {
410        { "Taken", "Pris" },
411        { "Taken late: tap to say when.", "Pris en retard : touchez pour dire à quelle heure." },
412        { "A dose is due: open Sioul to check it.", "Une prise est prévue : ouvrez Sioul pour vérifier." },
413    };
414
415    /**
416     * The few words said here, in Sioul's two languages (crates/sioul-core/
417     * locales), by the phone's language as Sioul takes it (android/main.cpp);
418     * all the others are Rust's.
419     */
420    private static String words(int which)
421    {
422        return WORDS[which]["fr".equals(Locale.getDefault().getLanguage()) ? 1 : 0];
423    }
424}