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.NotificationManager;
008import android.app.PendingIntent;
009import android.content.BroadcastReceiver;
010import android.content.Context;
011import android.content.Intent;
012import android.os.Build;
013import android.util.Log;
014
015/**
016 * Do-not-disturb applied in Sioul's own process (crates/sioul-app/src/
017 * everywhere.rs, `apply`), where Android's modes of Sioul's are kept
018 * (PauseMode): asked by the background service when your other devices
019 * changed it, at the alarm of its next end (the switch's "until", waking,
020 * the night), and after the phone's restart. Sioul's window open or not:
021 * the library is loaded alone, as for a dose ({@link DoseAlarms#load}), and Rust
022 * answers on a thread of its own, the broadcast held open meanwhile.
023 *
024 * Both ways (docs/do-not-disturb.md): the phone's own do-not-disturb heard as
025 * it changes, Sioul closed or not. Android sends a package with Do Not
026 * Disturb access ACTION_INTERRUPTION_FILTER_CHANGED (NotificationManagerService,
027 * "explicitly send the broadcast to all DND packages, even if they aren't
028 * currently running"), and the owner of a mode its
029 * ACTION_AUTOMATIC_ZEN_RULE_STATUS_CHANGED; both are protected broadcasts,
030 * which only the system sends, so this receiver stays not exported. Decided
031 * in Java (DndHeard), the library loaded only for a press; a change heard
032 * while Sioul's own settles is looked at again once it did. The notification
033 * listener, while bound, hears every change too (AppNotes,
034 * onInterruptionFilterChanged) and forwards it here (HEARD): whichever word
035 * comes first is decided, the other finds nothing new. Each decision is
036 * logged, without anything of the person's. The state found at a restart or
037 * an update is noted, so that the next change counts from it. Also Sioul's
038 * switch pressed on its quick-settings tile (DndTile).
039 */
040public final class DndReceiver extends BroadcastReceiver
041{
042    static final String APPLY = "com.aurelienpierre.sioul.action.DND_APPLY";
043    /** Sioul's switch pressed on its quick-settings tile (DndTile). */
044    static final String TOGGLE = "com.aurelienpierre.sioul.action.DND_TOGGLE";
045    /**
046     * The phone's interruption filter changed, as Sioul's notification listener
047     * heard it ({@link AppNotes#onInterruptionFilterChanged}, in its own process):
048     * decided here as Android's own broadcast is, whichever comes first.
049     */
050    static final String HEARD = "com.aurelienpierre.sioul.action.DND_HEARD";
051    private static final int CODE = 0x5138;
052    /** A look again waits this long at most, the broadcast held open meanwhile. */
053    private static final long WAIT_MS = 5_000;
054    /** Looked at again at most this often while Sioul's own changes follow each other. */
055    private static final int LOOKS = 3;
056    /**
057     * One decision at a time in this process: Android's broadcast and the
058     * listener's word of one change, decided together, would both see it new.
059     */
060    private static final Object DECIDING = new Object();
061
062    /** Do-not-disturb as the reasons say now, applied here (Rust); its answer, "" when none. */
063    static native String nativeApply();
064
065    /** A change of the phone's own do-not-disturb, heard as it happened: {on}. A press when it disagrees (Rust). */
066    static native String nativeHeard(String json);
067
068    /** Sioul's switch pressed on its tile: on, or off, as it is not now (Rust). */
069    static native String nativeToggle();
070
071    @Override
072    public void onReceive(Context context, Intent intent)
073    {
074        String action = intent.getAction();
075        if (action == null)
076            return;
077        final Context app = context.getApplicationContext();
078        switch (action) {
079        case Intent.ACTION_BOOT_COMPLETED:
080        case Intent.ACTION_MY_PACKAGE_REPLACED:
081            // The phone's state found, not heard: noted, so that the next change counts from what holds now.
082            PauseMode.noteSeen(app);
083            later(goAsync(), "applied", () -> {
084                DoseAlarms.load(app);
085                nativeApply();
086            });
087            return;
088        case APPLY:
089            later(goAsync(), "applied", () -> {
090                DoseAlarms.load(app);
091                nativeApply();
092            });
093            return;
094        case TOGGLE:
095            later(goAsync(), "pressed", () -> {
096                DoseAlarms.load(app);
097                nativeToggle();
098            });
099            return;
100        case NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED:
101        case NotificationManager.ACTION_AUTOMATIC_ZEN_RULE_STATUS_CHANGED:
102            heard(app, intent, goAsync());
103            return;
104        case HEARD:
105            // The listener's word: the filter, as Android's broadcast says it.
106            heard(app, new Intent(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED), goAsync());
107            return;
108        default:
109            return;
110        }
111    }
112
113    /** A change of the phone's own do-not-disturb: decided, looked at again once Sioul's own change settled, pressed (Rust). */
114    private static void heard(Context app, Intent intent, PendingResult held)
115    {
116        final String action = intent.getAction();
117        final String id = intent.getStringExtra(NotificationManager.EXTRA_AUTOMATIC_ZEN_RULE_ID);
118        final int status = intent.getIntExtra(NotificationManager.EXTRA_AUTOMATIC_ZEN_RULE_STATUS, NotificationManager.AUTOMATIC_RULE_STATUS_UNKNOWN);
119        later(held, "heard", () -> {
120            String kind = PauseMode.kindOfRule(app, id);
121            String said = decided(app, action, status, kind, id);
122            for (int look = 1; look < LOOKS && said.startsWith(DndHeard.LATER); look++) {
123                long wait = Math.min(WAIT_MS, Long.parseLong(said.substring(DndHeard.LATER.length())));
124                Thread.sleep(Math.max(0, wait));
125                said = decided(app, action, status, kind, id);
126            }
127            Log.i(DoseAlarms.TAG, "Do not disturb: " + (NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED.equals(action) ? "the filter" : "a mode's status " + status)
128                                  + " heard: " + (said.isEmpty() ? "nothing for Sioul" : said) + ".");
129            if (!"on".equals(said) && !"off".equals(said))
130                return;
131            Log.i(DoseAlarms.TAG, "Do not disturb: the phone's own turned " + said + ", heard.");
132            DoseAlarms.load(app);
133            nativeHeard("{\"on\":" + "on".equals(said) + "}");
134        });
135    }
136
137    /** One decision ({@link PauseMode#decide}), never two at once in this process. */
138    private static String decided(Context app, String action, int status, String kind, String id)
139    {
140        synchronized (DECIDING) {
141            return PauseMode.decide(app, action, status, kind, id);
142        }
143    }
144
145    /** The listener's word of a change (AppNotes, in its own process): decided in Sioul's own. */
146    static void forward(Context context)
147    {
148        context.sendBroadcast(new Intent(context, DndReceiver.class).setAction(HEARD));
149    }
150
151    /** Work that may wait, on a thread of its own, the broadcast held open meanwhile. */
152    private interface Work
153    {
154        void run() throws Exception;
155    }
156
157    private static void later(PendingResult held, String what, Work work)
158    {
159        new Thread(() -> {
160            try {
161                work.run();
162            } catch (Throwable e) {
163                Log.e(DoseAlarms.TAG, "Do not disturb: not " + what, e);
164            } finally {
165                held.finish();
166            }
167        }, "sioul-dnd").start();
168    }
169
170    /** Asked from the background service's process: applied in Sioul's own. */
171    static void poke(Context context)
172    {
173        context.sendBroadcast(new Intent(context, DndReceiver.class).setAction(APPLY));
174    }
175
176    /**
177     * Applied again at `atMillis` (Unix milliseconds), the next end Rust
178     * gave; 0 takes the alarm away. Exact through Doze while allowed, else
179     * within Android's inexact window.
180     */
181    static void schedule(Context context, long atMillis)
182    {
183        AlarmManager alarms = context.getSystemService(AlarmManager.class);
184        PendingIntent apply = PendingIntent.getBroadcast(context, CODE, new Intent(context, DndReceiver.class).setAction(APPLY),
185                                                         PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
186        if (atMillis <= 0) {
187            alarms.cancel(apply);
188            return;
189        }
190        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms())
191            alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, apply);
192        else
193            alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, apply);
194    }
195}