001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.annotation.TargetApi;
007import android.app.AutomaticZenRule;
008import android.app.NotificationChannel;
009import android.app.NotificationManager;
010import android.content.ActivityNotFoundException;
011import android.content.ComponentName;
012import android.content.Context;
013import android.content.Intent;
014import android.content.SharedPreferences;
015import android.net.Uri;
016import android.os.Build;
017import android.os.SystemClock;
018import android.provider.Settings;
019import android.service.notification.Condition;
020import android.service.notification.ZenPolicy;
021import android.service.quicksettings.TileService;
022import android.util.Log;
023
024import org.json.JSONException;
025import org.json.JSONObject;
026
027import java.util.Map;
028
029/**
030 * The phone's do-not-disturb during Sioul's pauses and for its own
031 * do-not-disturb (crates/sioul-app/src/dnd.rs, docs/android.md "Pauses"): a
032 * mode of Sioul's own for each, an AutomaticZenRule named "Pause" / "En
033 * pause", "Free time" / "Temps libre" or "Do not disturb (Sioul)",
034 * letting through what the matrix of what reaches you lets through then, as
035 * far as Android can say it (sioul_core::attention::Silence, docs/attention.md
036 * §3.3): calls and messages from nobody, starred contacts, contacts or
037 * anyone; a second call within 15 minutes; the conversations marked
038 * important in Android (Android 11 and later); alarms; the sound of what the
039 * person plays. Sioul's dose reminders and an event's alarms, through
040 * channels of their own that pass it. Turned on and off with
041 * setAutomaticZenRuleState: the person's own do-not-disturb, and every other
042 * mode, is never touched; the system combines them, the strictest winning.
043 * Needs "Do Not Disturb access" (Modes access from Android 15), which the
044 * person gives in Android's settings; without it, nothing is done and Rust
045 * says so. Also the tile and the shortcut's press (PauseOpener, PauseTile).
046 *
047 * Both ways (docs/do-not-disturb.md): before each change of its own, Sioul
048 * notes until when it settles (`changing_until`), so that DndReceiver never
049 * takes it for the person's; a mode is recorded on before Android is asked to
050 * turn it on. A switch "on" taken from the phone's own do-not-disturb adds no
051 * mode of Sioul's over it (`stack` false: {@code held.<kind>}, its channels that
052 * pass still used). Sioul's going off turns the phone's own off too, up to
053 * Android 14 (`quiet-off`).
054 *
055 * Rust asks through call(verb, json) (android/main.cpp), and is answered in JSON.
056 */
057final class PauseMode
058{
059    private static final String TAG = DoseAlarms.TAG;
060    /** The dose reminders' channel while a pause lets them through: it passes do-not-disturb. */
061    static final String DOSES_CHANNEL = "doses-pause";
062    private static final String DOSES_USUAL = "doses";
063    /** An event's alarms while one of Sioul's modes lets them through: it passes do-not-disturb (EventAlarms). */
064    static final String ALARMS_CHANNEL = "event-alarms-pause";
065    private static final String KEPT = "sioul-pause";
066    private static final String PRESSED = "pressed";
067    /** A press older than this is not taken: Sioul did not come up meanwhile. */
068    private static final long PRESS_KEPT_MS = 5 * 60 * 1000L;
069    /** Sioul's modes: the two pauses, and its do-not-disturb on every device. */
070    private static final String[] KINDS = { "pause", "free-time", "dnd" };
071    /** Until when Sioul's own change settles (SystemClock.elapsedRealtime): DndReceiver looks again after. */
072    static final String CHANGING = "changing_until";
073    /** How long Sioul's own change of a mode takes to settle. */
074    private static final long SETTLE_MS = 3_000;
075    /** The phone's do-not-disturb as DndReceiver last saw it: on or off. */
076    static final String SEEN = "seen_on";
077    /** Sioul's do-not-disturb holds on this phone, as Rust said at its last apply (the tile, DndReceiver). */
078    static final String SIOUL_ON = "sioul_on";
079
080    private PauseMode() {}
081
082    /** Rust's question (android/main.cpp): "can", "enter", "leave", "pressed", "open". Any thread. */
083    static String call(Context context, String verb, String json)
084    {
085        try {
086            switch (verb) {
087            case "can":
088                return can(context).toString();
089            case "enter":
090                return enter(context, new JSONObject(json)).toString();
091            case "leave":
092                return leave(context, json).toString();
093            case "pressed":
094                return takePressed(context) ? "true" : "false";
095            case "open":
096                open(context, json);
097                return "{}";
098            case "holds":
099                return holds(context, json).toString();
100            case "own":
101                return own(context).toString();
102            case "quiet-off":
103                return quietOff(context).toString();
104            case "flags":
105                flags(context, new JSONObject(json));
106                return "{}";
107            default:
108                return null;
109            }
110        } catch (JSONException | RuntimeException e) {
111            Log.e(TAG, "Pause: " + verb + " failed: " + e);
112            JSONObject answer = new JSONObject();
113            put(answer, "access", accessGranted(context));
114            put(answer, "error", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
115            return answer.toString();
116        }
117    }
118
119    // ---------------------------------------------------------------- what Android allows
120
121    /** Modes with their own policy and state, set by the app: Android 10 (API 29). */
122    private static boolean tooOld()
123    {
124        return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q;
125    }
126
127    private static NotificationManager notifications(Context context)
128    {
129        return context.getSystemService(NotificationManager.class);
130    }
131
132    private static boolean accessGranted(Context context)
133    {
134        return notifications(context).isNotificationPolicyAccessGranted();
135    }
136
137    private static JSONObject can(Context context)
138    {
139        JSONObject answer = new JSONObject();
140        put(answer, "api", Build.VERSION.SDK_INT);
141        if (tooOld()) {
142            put(answer, "too_old", true);
143            return answer;
144        }
145        put(answer, "access", accessGranted(context));
146        return answer;
147    }
148
149    // ---------------------------------------------------------------- on, off
150
151    /**
152     * What one of Sioul's modes lets through, as Rust asks it (dnd.rs's Ask,
153     * from the matrix: sioul_core::attention::Silence): who may call and who
154     * may write ("none", "starred", "contacts", "anyone"), a second call
155     * within 15 minutes, the conversations marked important in Android,
156     * alarms, the doses' and an event's alarms' channels. An older Sioul says
157     * only "people": starred contacts and repeat callers, or nobody; no
158     * conversation. Pure, checked on a JVM (android/jvm-checks/ModeCheck.java).
159     */
160    static final class Through
161    {
162        /** ZenPolicy's PEOPLE_TYPE_*. */
163        int calls;
164        int messages;
165        boolean repeat;
166        boolean conversations;
167        boolean alarms;
168        boolean doses;
169        boolean events;
170
171        static Through of(JSONObject asked)
172        {
173            // The matrix's words beside the older ones (dnd.rs), or apart, as "silence".
174            JSONObject said = asked.optJSONObject("silence");
175            if (said == null)
176                said = asked;
177            Through through = new Through();
178            int usual = asked.optBoolean("people", true) ? ZenPolicy.PEOPLE_TYPE_STARRED : ZenPolicy.PEOPLE_TYPE_NONE;
179            through.calls = senders(said.optString("calls", ""), usual);
180            through.messages = senders(said.optString("messages", ""), usual);
181            through.repeat = said.optBoolean("repeat", through.calls != ZenPolicy.PEOPLE_TYPE_NONE);
182            through.conversations = said.optBoolean("conversations", false);
183            through.alarms = said.optBoolean("alarms", true);
184            through.doses = said.optBoolean("doses", asked.optBoolean("doses", true));
185            // An event's alarm is shown during a mode only when the matrix lets it come then (Rust decides).
186            through.events = said.optBoolean("events", true);
187            return through;
188        }
189
190        /** Android's senders for Rust's word; `usual` for none given, or a word not known. */
191        static int senders(String word, int usual)
192        {
193            switch (word) {
194            case "none":
195                return ZenPolicy.PEOPLE_TYPE_NONE;
196            case "starred":
197                return ZenPolicy.PEOPLE_TYPE_STARRED;
198            case "contacts":
199                return ZenPolicy.PEOPLE_TYPE_CONTACTS;
200            case "anyone":
201                return ZenPolicy.PEOPLE_TYPE_ANYONE;
202            default:
203                return usual;
204            }
205        }
206
207        /** The conversations let through, as Android names them (Android 11 and later). */
208        int conversationSenders()
209        {
210            return conversations ? ZenPolicy.CONVERSATION_SENDERS_IMPORTANT : ZenPolicy.CONVERSATION_SENDERS_NONE;
211        }
212    }
213
214    /**
215     * The pause's mode on, made or brought up to date first: {kind, name,
216     * trigger, calls, messages, repeat, conversations, alarms, doses,
217     * events, channel, events_channel} (an older Sioul: {kind, name,
218     * trigger, people, doses, channel}). Answers what the mode lets through,
219     * read back from Android (the person may have changed it in Android's
220     * settings): {api, access, rule: {name, calls, messages, repeat, alarms,
221     * conversations (Android 11 and later), channels (15 and later)}, doses,
222     * events, already, disabled, turned_off}.
223     */
224    @TargetApi(Build.VERSION_CODES.Q)
225    private static JSONObject enter(Context context, JSONObject asked)
226    {
227        JSONObject answer = new JSONObject();
228        if (tooOld()) {
229            put(answer, "too_old", true);
230            return answer;
231        }
232        put(answer, "api", Build.VERSION.SDK_INT);
233        boolean access = accessGranted(context);
234        put(answer, "access", access);
235        if (!access)
236            return answer;
237        NotificationManager notifications = notifications(context);
238        String kind = asked.optString("kind", "pause");
239        Through through = Through.of(asked);
240        String name = asked.optString("name", "pause".equals(kind) ? "Pause" : "Free time");
241        Uri condition = conditionId(context, kind);
242        ZenPolicy policy = policy(through);
243        SharedPreferences kept = kept(context);
244        boolean wasOn = kept.getBoolean("on." + kind, false);
245
246        // The channels that pass first: a reminder due meanwhile finds them.
247        boolean dosesThrough = false;
248        if (through.doses)
249            dosesThrough = passingChannel(notifications, DOSES_CHANNEL, asked.optString("channel", "Doses during a pause"));
250        boolean eventsThrough = false;
251        if (through.events)
252            eventsThrough = passingChannel(notifications, ALARMS_CHANNEL, asked.optString("events_channel", EventAlarms.word(context, "alarms-pause")));
253
254        String id = null;
255        AutomaticZenRule existing = null;
256        Map<String, AutomaticZenRule> rules = notifications.getAutomaticZenRules();
257        for (Map.Entry<String, AutomaticZenRule> rule : rules.entrySet()) {
258            if (condition.equals(rule.getValue().getConditionId())) {
259                id = rule.getKey();
260                existing = rule.getValue();
261                break;
262            }
263        }
264        // A mode the person turned off in Android's settings stays off.
265        boolean enabled = existing == null || existing.isEnabled();
266        AutomaticZenRule wanted = rule(context, name, asked.optString("trigger", ""), condition, policy, enabled);
267        // Held by the phone's own do-not-disturb, which turned Sioul's on: no mode of
268        // Sioul's over it (its end must show), its channels that pass still used.
269        if (!asked.optBoolean("stack", true)) {
270            if (wasOn && id != null) {
271                kept.edit().putBoolean("on." + kind, false).putLong(CHANGING, SystemClock.elapsedRealtime() + SETTLE_MS).commit();
272                notifications.setAutomaticZenRuleState(id, state(condition, name, false, false));
273            }
274            kept.edit().putBoolean("on." + kind, false).putBoolean("held." + kind, true).putBoolean("doses." + kind, through.doses).putBoolean("events." + kind, through.events).commit();
275            put(answer, DndHeard.seen(notifications.getCurrentInterruptionFilter()) ? "held" : "turned_off", true);
276            put(answer, "doses", dosesThrough);
277            put(answer, "events", eventsThrough);
278            return answer;
279        }
280        kept.edit().remove("held." + kind).commit();
281        boolean fresh = !wasOn;
282        // Sioul's own change from here: a change heard meanwhile is looked at once it settled.
283        kept.edit().putLong(CHANGING, SystemClock.elapsedRealtime() + SETTLE_MS).commit();
284        if (existing == null) {
285            id = notifications.addAutomaticZenRule(wanted);
286            fresh = true;
287            Log.i(TAG, "Pause: the mode \"" + kind + "\" made.");
288        } else if (!same(existing, wanted)) {
289            // An update that changes the mode turns it off (up to Android 14,
290            // any update): on again below, without overriding the person.
291            notifications.updateAutomaticZenRule(id, wanted);
292            fresh = true;
293            Log.i(TAG, "Pause: the mode \"" + kind + "\" brought up to date.");
294        }
295        boolean others = otherOn(kept, kind);
296        if (!enabled) {
297            put(answer, "disabled", true);
298        } else if (fresh) {
299            int filter = notifications.getCurrentInterruptionFilter();
300            boolean already = !wasOn && !others && filter != NotificationManager.INTERRUPTION_FILTER_ALL && filter != NotificationManager.INTERRUPTION_FILTER_UNKNOWN;
301            if (!wasOn)
302                kept.edit().putBoolean("already." + kind, already).commit();
303            put(answer, "already", already);
304            // Recorded on before Android is asked: its word of the change finds Sioul wanting it.
305            kept.edit().putBoolean("on." + kind, true).putLong(CHANGING, SystemClock.elapsedRealtime() + SETTLE_MS).commit();
306            try {
307                notifications.setAutomaticZenRuleState(id, state(condition, name, true, !wasOn));
308            } catch (RuntimeException e) {
309                kept.edit().putBoolean("on." + kind, wasOn).commit();
310                throw e;
311            }
312        } else if (turnedOff(notifications, id)) {
313            // Turned off by the person during the pause (the quick settings): left off.
314            put(answer, "turned_off", true);
315        }
316        kept.edit().putBoolean("on." + kind, enabled).putBoolean("doses." + kind, through.doses).putBoolean("events." + kind, through.events).commit();
317
318        AutomaticZenRule now = notifications.getAutomaticZenRule(id);
319        ZenPolicy set = now == null ? null : now.getZenPolicy();
320        JSONObject rule = new JSONObject();
321        put(rule, "name", now == null ? name : now.getName());
322        if (set != null) {
323            put(rule, "calls", set.getPriorityCallSenders());
324            put(rule, "messages", set.getPriorityMessageSenders());
325            put(rule, "repeat", set.getPriorityCategoryRepeatCallers());
326            put(rule, "alarms", set.getPriorityCategoryAlarms());
327            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
328                put(rule, "conversations", set.getPriorityConversationSenders());
329            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
330                int channels = set.getPriorityChannelsAllowed();
331                put(rule, "channels", channels);
332                if (channels == ZenPolicy.STATE_DISALLOW) {
333                    dosesThrough = false;
334                    eventsThrough = false;
335                }
336            }
337        }
338        put(answer, "rule", rule);
339        put(answer, "doses", dosesThrough);
340        put(answer, "events", eventsThrough);
341        return answer;
342    }
343
344    /** The pause's mode off, Sioul's own and nothing else: {was, still}. */
345    @TargetApi(Build.VERSION_CODES.Q)
346    private static JSONObject leave(Context context, String kind)
347    {
348        JSONObject answer = new JSONObject();
349        if (tooOld()) {
350            put(answer, "too_old", true);
351            return answer;
352        }
353        SharedPreferences kept = kept(context);
354        boolean was = kept.getBoolean("on." + kind, false) || kept.getBoolean("held." + kind, false);
355        boolean already = kept.getBoolean("already." + kind, false);
356        kept.edit().putBoolean("on." + kind, false).remove("held." + kind).remove("already." + kind)
357            .putLong(CHANGING, SystemClock.elapsedRealtime() + SETTLE_MS).commit();
358        put(answer, "was", was);
359        NotificationManager notifications = notifications(context);
360        if (!notifications.isNotificationPolicyAccessGranted()) {
361            // Taking the access back removed Sioul's modes: nothing is left on.
362            put(answer, "access", false);
363            return answer;
364        }
365        Uri condition = conditionId(context, kind);
366        for (Map.Entry<String, AutomaticZenRule> rule : notifications.getAutomaticZenRules().entrySet()) {
367            if (condition.equals(rule.getValue().getConditionId()))
368                notifications.setAutomaticZenRuleState(rule.getKey(), state(condition, rule.getValue().getName(), false, true));
369        }
370        int filter = notifications.getCurrentInterruptionFilter();
371        // Said only of a do-not-disturb on before the pause, and still on.
372        put(answer, "still", already && !otherOn(kept, kind) && filter != NotificationManager.INTERRUPTION_FILTER_ALL && filter != NotificationManager.INTERRUPTION_FILTER_UNKNOWN);
373        Log.i(TAG, "Pause: the mode \"" + kind + "\" off" + (was ? "." : " (it was not on)."));
374        return answer;
375    }
376
377    /**
378     * Who gets through, every field set: one left unset would take the
379     * person's own do-not-disturb settings. Calls and messages as Rust asks
380     * (nobody, starred contacts, contacts, anyone), a second call within 15
381     * minutes, the conversations marked important (Android 11 and later;
382     * Android 10 has no conversations: they are messages there); alarms;
383     * media (the music the person plays); priority channels (Sioul's doses
384     * and an event's alarms, from Android 15; before, Android lets a channel
385     * that passes through whatever the mode says). Other apps' reminders and
386     * events stay held. Notifications held stay in the shade and the status
387     * bar, without sound, banner, light, full screen or ambient display, as
388     * Android's own do-not-disturb does by default; they come back after.
389     */
390    @TargetApi(Build.VERSION_CODES.Q)
391    private static ZenPolicy policy(Through through)
392    {
393        ZenPolicy.Builder policy = new ZenPolicy.Builder()
394            .allowCalls(through.calls)
395            .allowMessages(through.messages)
396            .allowRepeatCallers(through.repeat)
397            .allowAlarms(through.alarms)
398            .allowMedia(true)
399            .allowSystem(false)
400            .allowReminders(false)
401            .allowEvents(false)
402            .showPeeking(false)
403            .showFullScreenIntent(false)
404            .showLights(false)
405            .showInAmbientDisplay(false)
406            .showStatusBarIcons(true)
407            .showBadges(true)
408            .showInNotificationList(true);
409        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
410            policy.allowConversations(through.conversationSenders());
411        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM)
412            policy.allowPriorityChannels(true);
413        return policy.build();
414    }
415
416    /**
417     * The mode as Sioul makes it. Its settings in Android open Sioul (the
418     * configuration activity a mode needs without a condition provider).
419     * From Android 15, a mode of its own kind with its words and icon, not
420     * turned on from Android's settings (only a pause turns it on).
421     */
422    @TargetApi(Build.VERSION_CODES.Q)
423    private static AutomaticZenRule rule(Context context, String name, String trigger, Uri condition, ZenPolicy policy, boolean enabled)
424    {
425        ComponentName sioul = window(context);
426        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
427            AutomaticZenRule.Builder rule = new AutomaticZenRule.Builder(name, condition)
428                .setType(AutomaticZenRule.TYPE_OTHER)
429                .setConfigurationActivity(sioul)
430                .setZenPolicy(policy)
431                .setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
432                .setIconResId(R.drawable.sioul_pause)
433                .setManualInvocationAllowed(false)
434                .setEnabled(enabled);
435            if (!trigger.isEmpty())
436                rule.setTriggerDescription(trigger);
437            return rule.build();
438        }
439        return new AutomaticZenRule(name, null, sioul, condition, policy, NotificationManager.INTERRUPTION_FILTER_PRIORITY, enabled);
440    }
441
442    /** Whether a mode is already as Sioul would make it. */
443    @TargetApi(Build.VERSION_CODES.Q)
444    private static boolean same(AutomaticZenRule existing, AutomaticZenRule wanted)
445    {
446        boolean same = wanted.getName().equals(existing.getName())
447            && existing.getInterruptionFilter() == wanted.getInterruptionFilter()
448            && existing.isEnabled() == wanted.isEnabled()
449            && wanted.getZenPolicy().equals(existing.getZenPolicy());
450        if (same && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
451            String trigger = wanted.getTriggerDescription();
452            same = trigger == null || trigger.equals(existing.getTriggerDescription());
453        }
454        return same;
455    }
456
457    /** The mode's state as Sioul asks it: a press (or its end) is the person's own act. */
458    private static Condition state(Uri condition, String name, boolean on, boolean pressed)
459    {
460        int state = on ? Condition.STATE_TRUE : Condition.STATE_FALSE;
461        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM)
462            return new Condition(condition, name, state, pressed ? Condition.SOURCE_USER_ACTION : Condition.SOURCE_UNKNOWN);
463        return new Condition(condition, name, state);
464    }
465
466    /**
467     * Whether the person turned the mode off while it was on. Android 15's
468     * getAutomaticZenRuleState gives Sioul's own condition and misses a
469     * snooze from the quick settings (AOSP 16: "Buggy, does not consider
470     * snoozing"); with the Modes UI of Android 16 it says the mode's state.
471     * The filter at ALL says it on every version: while one of Sioul's modes
472     * is on and not snoozed, the phone is never at ALL.
473     */
474    @TargetApi(Build.VERSION_CODES.Q)
475    private static boolean turnedOff(NotificationManager notifications, String id)
476    {
477        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && notifications.getAutomaticZenRuleState(id) != Condition.STATE_TRUE)
478            return true;
479        return notifications.getCurrentInterruptionFilter() == NotificationManager.INTERRUPTION_FILTER_ALL;
480    }
481
482    /** Sioul's own address for a pause's mode: condition://com.aurelienpierre.sioul/pause. */
483    private static Uri conditionId(Context context, String kind)
484    {
485        return new Uri.Builder().scheme(Condition.SCHEME).authority(context.getPackageName()).appendPath(kind).build();
486    }
487
488    /** Sioul's window, as its icon opens it. */
489    private static ComponentName window(Context context)
490    {
491        Intent launcher = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
492        ComponentName window = launcher == null ? null : launcher.getComponent();
493        return window != null ? window : new ComponentName(context.getPackageName(), "org.qtproject.qt.android.bindings.QtActivity");
494    }
495
496    private static boolean otherOn(SharedPreferences kept, String kind)
497    {
498        for (String other : KINDS)
499            if (!other.equals(kind) && kept.getBoolean("on." + other, false))
500                return true;
501        return false;
502    }
503
504    // ---------------------------------------------------------------- the channels that pass
505
506    /**
507     * A channel that passes do-not-disturb, for what one of Sioul's modes
508     * lets through: the doses ("Doses during a pause"), an event's alarms,
509     * the mail of someone Always through. At Android's default importance
510     * (its sound, no banner), allowed to pass with Do Not Disturb access, and
511     * only while the person has not changed the channel: then their choice
512     * stays. The usual channels are never changed: outside Sioul's modes, the
513     * person's own do-not-disturb holds them as before. Whether it gets
514     * through, as Android has the channel now.
515     */
516    private static boolean passingChannel(NotificationManager notifications, String id, String name)
517    {
518        NotificationChannel channel = Channels.quiet(id, name, NotificationManager.IMPORTANCE_DEFAULT);
519        channel.setBypassDnd(true);
520        notifications.createNotificationChannel(channel);
521        NotificationChannel kept = notifications.getNotificationChannel(id);
522        return kept != null && kept.canBypassDnd() && kept.getImportance() != NotificationManager.IMPORTANCE_NONE && notifications.areNotificationsEnabled();
523    }
524
525    /**
526     * Whether a channel that passes is the one now: one of Sioul's modes is
527     * on, and every mode on lets `flag` through ("doses.", "events."; null:
528     * any mode on will do), as `kept` has them (SharedPreferences.getAll).
529     * Pure, checked on a JVM (android/jvm-checks/: ModeCheck, HeardCheck).
530     */
531    static boolean passesNow(Map<String, ?> kept, String flag)
532    {
533        boolean on = false;
534        for (String kind : KINDS) {
535            // A mode held by the phone's own do-not-disturb counts as Sioul's: its channels pass.
536            if (!Boolean.TRUE.equals(kept.get("on." + kind)) && !Boolean.TRUE.equals(kept.get("held." + kind)))
537                continue;
538            // Not said (a mode entered by an older Sioul): let through, as before.
539            if (flag != null && Boolean.FALSE.equals(kept.get(flag + kind)))
540                return false;
541            on = true;
542        }
543        return on;
544    }
545
546    private static String channelNow(Context context, String flag, String passing, String usual)
547    {
548        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !passesNow(kept(context).getAll(), flag))
549            return usual;
550        return notifications(context).getNotificationChannel(passing) != null ? passing : usual;
551    }
552
553    /**
554     * The channel a dose reminder goes to now (DoseAlarms): the pauses' own
555     * while every mode on lets the doses through, the usual one otherwise
556     * (a mode that holds them, or none).
557     */
558    static String dosesChannelNow(Context context)
559    {
560        return channelNow(context, "doses.", DOSES_CHANNEL, DOSES_USUAL);
561    }
562
563    /**
564     * The channel an event's alarm goes to now (EventAlarms): the one that
565     * passes while every mode on lets an event's alarms through, the usual
566     * "An event's alarms" otherwise. Sioul's reminders before an event never
567     * come here: they stay in the quiet "Events".
568     */
569    static String alarmsChannelNow(Context context)
570    {
571        return channelNow(context, "events.", ALARMS_CHANNEL, EventAlarms.ALARMS);
572    }
573
574    /** New mail's channel that passes, for someone Always through while one of Sioul's modes is on (MailNotes). */
575    static final String MAIL_CHANNEL = "mail-through";
576
577    /**
578     * The channel new mail goes to now (MailNotes, from the window's process
579     * or the background service's): the one that passes while one of Sioul's
580     * modes is on and someone Always through wrote (`through`, Rust's word:
581     * told through the system's do-not-disturb), made then with `name`; the
582     * quiet "New mail" otherwise.
583     */
584    static String mailChannelNow(Context context, boolean through, String name)
585    {
586        if (!through || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !passesNow(kept(context).getAll(), null))
587            return MailNotes.CHANNEL;
588        NotificationManager notifications = notifications(context);
589        if (accessGranted(context))
590            passingChannel(notifications, MAIL_CHANNEL, name);
591        return notifications.getNotificationChannel(MAIL_CHANNEL) != null ? MAIL_CHANNEL : MailNotes.CHANNEL;
592    }
593
594    // ---------------------------------------------------------------- the tile and the shortcut
595
596    /** "Pause" pressed on the tile or the home screen's shortcut (PauseOpener). */
597    static void pressed(Context context)
598    {
599        kept(context).edit().putLong(PRESSED, System.currentTimeMillis()).commit();
600        Log.i(TAG, "Pause: pressed, kept for Sioul.");
601    }
602
603    /** A press since last asked, and recent; once. */
604    private static boolean takePressed(Context context)
605    {
606        SharedPreferences kept = kept(context);
607        long at = kept.getLong(PRESSED, 0);
608        if (at == 0)
609            return false;
610        kept.edit().remove(PRESSED).commit();
611        long age = System.currentTimeMillis() - at;
612        return age >= 0 && age < PRESS_KEPT_MS;
613    }
614
615    /** Whether the pause for overwhelming moments is on here: the tile shows it. */
616    static boolean paused(Context context)
617    {
618        return kept(context).getBoolean("on.pause", false);
619    }
620
621    // ---------------------------------------------------------------- both ways
622
623    /** The kind of a mode from its condition id ({@code "condition://<package>/dnd"}); null when not Sioul's. Pure. */
624    static String kindOf(String conditionId, String app)
625    {
626        String prefix = Condition.SCHEME + "://" + app + "/";
627        if (conditionId == null || !conditionId.startsWith(prefix))
628            return null;
629        String kind = conditionId.substring(prefix.length());
630        for (String known : KINDS)
631            if (known.equals(kind))
632                return kind;
633        return null;
634    }
635
636    /** The kind of Sioul's mode with this id, as Android has it; null when none. */
637    @TargetApi(Build.VERSION_CODES.Q)
638    static String kindOfRule(Context context, String id)
639    {
640        if (tooOld() || id == null || !accessGranted(context))
641            return null;
642        AutomaticZenRule rule = notifications(context).getAutomaticZenRule(id);
643        return rule == null ? null : kindOf(String.valueOf(rule.getConditionId()), context.getPackageName());
644    }
645
646    /** Sioul's mode with this id as Android has it now (DndHeard.LIVE_*). */
647    @TargetApi(Build.VERSION_CODES.Q)
648    static int live(Context context, String id)
649    {
650        if (tooOld() || id == null || !accessGranted(context))
651            return DndHeard.LIVE_UNKNOWN;
652        NotificationManager notifications = notifications(context);
653        AutomaticZenRule rule = notifications.getAutomaticZenRule(id);
654        if (rule == null)
655            return DndHeard.LIVE_GONE;
656        if (!rule.isEnabled())
657            return DndHeard.LIVE_DISABLED;
658        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM)
659            return notifications.getAutomaticZenRuleState(id) == Condition.STATE_TRUE ? DndHeard.LIVE_ON : DndHeard.LIVE_OFF;
660        return DndHeard.LIVE_UNKNOWN;
661    }
662
663    /**
664     * A change heard by DndReceiver, decided ({@link DndHeard#decide}) from what
665     * Android has now and Sioul's record; the phone's state noted as seen,
666     * unless the decision waits for Sioul's own change to settle.
667     */
668    static String decide(Context context, String action, int status, String kind, String id)
669    {
670        SharedPreferences kept = kept(context);
671        boolean access = !tooOld() && accessGranted(context);
672        int filter = access ? notifications(context).getCurrentInterruptionFilter() : NotificationManager.INTERRUPTION_FILTER_UNKNOWN;
673        String said = DndHeard.decide(access, kept.getAll(), action, status, kind, live(context, id), filter, SystemClock.elapsedRealtime());
674        if (access && !said.startsWith(DndHeard.LATER) && filter != NotificationManager.INTERRUPTION_FILTER_UNKNOWN)
675            kept.edit().putBoolean(SEEN, DndHeard.seen(filter)).commit();
676        return said;
677    }
678
679    /**
680     * At a restart or an update: the phone's state found, not heard, noted as
681     * seen, so that the next change counts from what holds now (forgetting it
682     * made the first change after each update a "first sight", never heard).
683     * Without the access, nothing is known: forgotten.
684     */
685    static void noteSeen(Context context)
686    {
687        SharedPreferences kept = kept(context);
688        int filter = tooOld() || !accessGranted(context) ? NotificationManager.INTERRUPTION_FILTER_UNKNOWN : notifications(context).getCurrentInterruptionFilter();
689        Boolean noted = DndHeard.noted(filter);
690        if (noted == null)
691            kept.edit().remove(SEEN).commit();
692        else
693            kept.edit().putBoolean(SEEN, noted).commit();
694    }
695
696    /** Whether Sioul's do-not-disturb holds on this phone, as Rust last said (the tile). */
697    static boolean sioulOn(Context context)
698    {
699        return kept(context).getBoolean(SIOUL_ON, false);
700    }
701
702    /**
703     * Rust's word at each apply: {on}, Sioul's do-not-disturb here; the tile
704     * shown again when it changed. The phone's state noted when none was ever
705     * seen (a first install): the next change is heard.
706     */
707    private static void flags(Context context, JSONObject said)
708    {
709        boolean on = said.optBoolean("on", false);
710        SharedPreferences kept = kept(context);
711        if (!kept.contains(SEEN))
712            noteSeen(context);
713        if (kept.contains(SIOUL_ON) && kept.getBoolean(SIOUL_ON, false) == on)
714            return;
715        kept.edit().putBoolean(SIOUL_ON, on).commit();
716        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
717            TileService.requestListeningState(context, new ComponentName(context, DndTile.class));
718    }
719
720    /** {holds}: whether the mode of this kind is still on as Sioul turned it on (cheap; Rust asks at each look). */
721    @TargetApi(Build.VERSION_CODES.Q)
722    private static JSONObject holds(Context context, String kind)
723    {
724        JSONObject answer = new JSONObject();
725        SharedPreferences kept = kept(context);
726        if (tooOld() || !accessGranted(context) || !kept.getBoolean("on." + kind, false))
727            return answer;
728        NotificationManager notifications = notifications(context);
729        Uri condition = conditionId(context, kind);
730        for (Map.Entry<String, AutomaticZenRule> rule : notifications.getAutomaticZenRules().entrySet()) {
731            if (condition.equals(rule.getValue().getConditionId())) {
732                put(answer, "holds", rule.getValue().isEnabled() && !turnedOff(notifications, rule.getKey()));
733                return answer;
734            }
735        }
736        put(answer, "holds", false);
737        return answer;
738    }
739
740    /** {on}: the phone's own do-not-disturb on now, whatever holds it; {} without the access. */
741    private static JSONObject own(Context context)
742    {
743        JSONObject answer = new JSONObject();
744        if (tooOld() || !accessGranted(context))
745            return answer;
746        int filter = notifications(context).getCurrentInterruptionFilter();
747        if (filter != NotificationManager.INTERRUPTION_FILTER_UNKNOWN)
748            put(answer, "on", DndHeard.seen(filter));
749        return answer;
750    }
751
752    /**
753     * Sioul's do-not-disturb gone off on this phone: the phone's own off too,
754     * with Sioul's Do Not Disturb access, up to Android 14 (it also snoozes
755     * every other mode on then, until it ends by itself). From Android 15 an
756     * application may end only a mode of its own: {done: false}, and Rust says so.
757     */
758    private static JSONObject quietOff(Context context)
759    {
760        JSONObject answer = new JSONObject();
761        put(answer, "done", false);
762        if (tooOld() || Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM || !accessGranted(context))
763            return answer;
764        NotificationManager notifications = notifications(context);
765        if (!DndHeard.seen(notifications.getCurrentInterruptionFilter()))
766            return answer;
767        kept(context).edit().putLong(CHANGING, SystemClock.elapsedRealtime() + SETTLE_MS).commit();
768        notifications.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL);
769        Log.i(TAG, "Do not disturb: the phone's own turned off with Sioul's.");
770        put(answer, "done", true);
771        return answer;
772    }
773
774    // ---------------------------------------------------------------- Android's pages
775
776    /**
777     * "access": Android's list where Sioul is given Do Not Disturb access (no
778     * page there on some phones: Sioul's own page then). "starred": the
779     * Contacts app's starred list, where the person stars who may reach them
780     * (Sioul never writes stars), else the Contacts app.
781     */
782    private static void open(Context context, String which)
783    {
784        String app = context.getPackageName();
785        Intent[] pages;
786        if ("modes".equals(which)) {
787            // Android's own do-not-disturb, where the person turns it off (or a mode of Sioul's on again).
788            pages = new Intent[] {
789                new Intent(Settings.ACTION_ZEN_MODE_PRIORITY_SETTINGS),
790                new Intent(Settings.ACTION_SOUND_SETTINGS),
791                new Intent(Settings.ACTION_SETTINGS),
792            };
793        } else if ("starred".equals(which)) {
794            pages = new Intent[] {
795                new Intent("com.android.contacts.action.LIST_STARRED"),
796                Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_CONTACTS),
797            };
798        } else {
799            pages = new Intent[] {
800                new Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS),
801                new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app)),
802            };
803        }
804        for (Intent page : pages) {
805            try {
806                context.startActivity(page.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
807                return;
808            } catch (ActivityNotFoundException | SecurityException e) {
809                // The next one.
810            }
811        }
812        Log.w(TAG, "Pause: no page to open for \"" + which + "\".");
813    }
814
815    // ---------------------------------------------------------------- kept
816
817    /**
818     * What Sioul's own process keeps of its modes (the only one that turns
819     * them on and off), read again when it changed: new mail is told from
820     * the background service's process too (MailNotes).
821     */
822    @SuppressWarnings("deprecation")
823    private static SharedPreferences kept(Context context)
824    {
825        return context.getSharedPreferences(KEPT, Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
826    }
827
828    private static void put(JSONObject object, String name, Object value)
829    {
830        try {
831            object.put(name, value);
832        } catch (JSONException e) {
833            // Only for a name that is null or a number that is not one.
834        }
835    }
836}