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}