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}