001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.content.BroadcastReceiver;
007import android.content.Context;
008import android.content.Intent;
009import android.os.Handler;
010import android.os.Looper;
011import android.util.Log;
012
013import org.json.JSONException;
014import org.json.JSONObject;
015
016/**
017 * A dose's alarm, and the "Taken" of its reminder (DoseAlarms): each asks
018 * Rust's side (crates/sioul-app/src/alarms.rs) on a thread of its own, the
019 * broadcast held open meanwhile (goAsync). At an alarm, Rust first gives the
020 * sync app twenty seconds to bring your other devices' marks; Android gives
021 * the alarm's broadcast a minute (it comes from the background), the
022 * button's ten seconds (from the foreground). An expedited JobScheduler job
023 * would get longer, but waits for its turn, its quota and the phone's sleep,
024 * where a broadcast runs at once. Past the deadline the broadcast is let go,
025 * before Android takes Sioul for hung and stops it: an alarm's reminder then
026 * says to check in Sioul rather than nothing, and Rust's answer, if it comes,
027 * replaces it.
028 */
029public final class DoseReceiver extends BroadcastReceiver
030{
031    private static final long DEADLINE_MS = 45_000;
032    private static final long FOREGROUND_DEADLINE_MS = 8_000;
033
034    @Override
035    public void onReceive(Context context, Intent intent)
036    {
037        final String action = intent.getAction();
038        final String key = DoseAlarms.key(intent);
039        final boolean alarm = DoseAlarms.ALARM.equals(action);
040        if (key.isEmpty() || !(alarm || DoseAlarms.TAKEN.equals(action)))
041            return;
042        final Context app = context.getApplicationContext();
043        final Asked asked = new Asked(goAsync());
044        final Handler main = new Handler(Looper.getMainLooper());
045        final Runnable deadline = () -> asked.letGo(alarm ? app : null, key);
046        final boolean foreground = (intent.getFlags() & Intent.FLAG_RECEIVER_FOREGROUND) != 0;
047        main.postDelayed(deadline, foreground ? FOREGROUND_DEADLINE_MS : DEADLINE_MS);
048        new Thread(() -> {
049            String answer = null;
050            try {
051                DoseAlarms.load(app);
052                answer = alarm ? DoseAlarms.nativeDecide(key) : DoseAlarms.nativeTaken(key);
053            } catch (Throwable e) {
054                // Sioul's library that does not load, among others: said all the same.
055                Log.e(DoseAlarms.TAG, "Doses: no answer about " + key, e);
056            }
057            main.removeCallbacks(deadline);
058            asked.answer(app, intent, key, alarm, answer);
059        }, "sioul-dose").start();
060    }
061
062    /** One broadcast: answered in time, or let go at the deadline and answered after. */
063    private static final class Asked
064    {
065        private final PendingResult broadcast;
066        private boolean late;
067        private boolean finished;
068
069        Asked(PendingResult broadcast)
070        {
071            this.broadcast = broadcast;
072        }
073
074        /** The deadline: an alarm not answered yet says to check in Sioul; the broadcast goes. */
075        synchronized void letGo(Context alarm, String key)
076        {
077            if (finished)
078                return;
079            late = true;
080            if (alarm != null)
081                DoseAlarms.fallback(alarm, key);
082            finish();
083        }
084
085        synchronized void answer(Context context, Intent intent, String key, boolean alarm, String answer)
086        {
087            try {
088                if (alarm)
089                    decided(context, intent, key, answer, late);
090                else
091                    taken(context, intent, key, answer);
092            } catch (RuntimeException e) {
093                Log.e(DoseAlarms.TAG, "Doses: the answer about " + key + " could not be shown", e);
094            }
095            finish();
096        }
097
098        private void finish()
099        {
100            if (finished)
101                return;
102            finished = true;
103            broadcast.finish();
104        }
105    }
106
107    /**
108     * Rust's decision at a dose's time: the reminder, as Rust says it; or
109     * nothing, maybe asked again later (news from another device awaited).
110     */
111    private static void decided(Context context, Intent intent, String key, String answer, boolean late)
112    {
113        String name = text(intent.getStringExtra(DoseAlarms.NAME));
114        JSONObject said = parse(answer);
115        if (said == null) {
116            DoseAlarms.fallback(context, key);
117            return;
118        }
119        if (said.optBoolean("show")) {
120            String title = said.optString("title");
121            DoseAlarms.remind(context, key, title.isEmpty() ? "Sioul" : title, said.optString("body"), name, said.optString("taken"));
122            DoseAlarms.reminded(context, key);
123        } else if (late) {
124            // The reminder to check in Sioul, said for nothing.
125            DoseAlarms.dismiss(context, key);
126        }
127        long again = said.optLong("again_at");
128        if (again > 0)
129            DoseAlarms.again(context, key, again, name);
130    }
131
132    /**
133     * Rust's answer to "Taken": recorded, said quietly; more than half an hour
134     * late, when it was taken is asked in Sioul. Else the reminder stays as it
135     * was, its button still there.
136     */
137    private static void taken(Context context, Intent intent, String key, String answer)
138    {
139        String title = text(intent.getStringExtra(DoseAlarms.TITLE));
140        String name = text(intent.getStringExtra(DoseAlarms.NAME));
141        JSONObject said = parse(answer);
142        if (said != null && said.optBoolean("done"))
143            DoseAlarms.confirm(context, key, name.isEmpty() ? title : name, said.optString("line"));
144        else if (said != null && said.optBoolean("open"))
145            DoseAlarms.askWhen(context, key, title, said.optString("line"));
146        else
147            Log.w(DoseAlarms.TAG, "Doses: \"Taken\" not recorded for " + key + ": " + answer);
148    }
149
150    private static JSONObject parse(String answer)
151    {
152        if (answer == null)
153            return null;
154        try {
155            return new JSONObject(answer);
156        } catch (JSONException e) {
157            Log.e(DoseAlarms.TAG, "Doses: an answer that does not read: " + answer);
158            return null;
159        }
160    }
161
162    private static String text(String text)
163    {
164        return text == null ? "" : text;
165    }
166}