blob: 562d20d05429eebf02c9e29e17ad30526fe8cb17 [file] [log] [blame]
Aurimas Liutikas88c7ff12023-08-10 12:42:26 -07001/*
2 * Copyright (C) 2018 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.settingslib.notification;
18
19import android.annotation.Nullable;
20import android.app.ActivityManager;
21import android.app.AlarmManager;
22import android.app.AlertDialog;
23import android.app.NotificationManager;
24import android.content.Context;
25import android.content.DialogInterface;
26import android.net.Uri;
27import android.provider.Settings;
28import android.service.notification.Condition;
29import android.service.notification.ZenModeConfig;
30import android.text.TextUtils;
31import android.text.format.DateFormat;
32import android.util.Log;
33import android.util.Slog;
34import android.view.LayoutInflater;
35import android.view.View;
36import android.view.ViewGroup;
37import android.widget.CompoundButton;
38import android.widget.ImageView;
39import android.widget.LinearLayout;
40import android.widget.RadioButton;
41import android.widget.RadioGroup;
42import android.widget.ScrollView;
43import android.widget.TextView;
44
45import com.android.internal.annotations.VisibleForTesting;
46import com.android.internal.policy.PhoneWindow;
47import com.android.settingslib.R;
48
49import java.util.Arrays;
50import java.util.Calendar;
51import java.util.GregorianCalendar;
52import java.util.Locale;
53import java.util.Objects;
54
55public class EnableZenModeDialog {
56 private static final String TAG = "EnableZenModeDialog";
57 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
58
59 private static final int[] MINUTE_BUCKETS = ZenModeConfig.MINUTE_BUCKETS;
60 private static final int MIN_BUCKET_MINUTES = MINUTE_BUCKETS[0];
61 private static final int MAX_BUCKET_MINUTES = MINUTE_BUCKETS[MINUTE_BUCKETS.length - 1];
62 private static final int DEFAULT_BUCKET_INDEX = Arrays.binarySearch(MINUTE_BUCKETS, 60);
63
64 @VisibleForTesting
65 protected static final int FOREVER_CONDITION_INDEX = 0;
66 @VisibleForTesting
67 protected static final int COUNTDOWN_CONDITION_INDEX = 1;
68 @VisibleForTesting
69 protected static final int COUNTDOWN_ALARM_CONDITION_INDEX = 2;
70
71 private static final int SECONDS_MS = 1000;
72 private static final int MINUTES_MS = 60 * SECONDS_MS;
73
74 @Nullable
75 private final ZenModeDialogMetricsLogger mMetricsLogger;
76
77 @VisibleForTesting
78 protected Uri mForeverId;
79 private int mBucketIndex = -1;
80
81 @VisibleForTesting
82 protected NotificationManager mNotificationManager;
83 private AlarmManager mAlarmManager;
84 private int mUserId;
85 private boolean mAttached;
86
87 @VisibleForTesting
88 protected Context mContext;
89 private final int mThemeResId;
90 private final boolean mCancelIsNeutral;
91 @VisibleForTesting
92 protected TextView mZenAlarmWarning;
93 @VisibleForTesting
94 protected LinearLayout mZenRadioGroupContent;
95
96 private RadioGroup mZenRadioGroup;
97 private int MAX_MANUAL_DND_OPTIONS = 3;
98
99 @VisibleForTesting
100 protected LayoutInflater mLayoutInflater;
101
102 public EnableZenModeDialog(Context context) {
103 this(context, 0);
104 }
105
106 public EnableZenModeDialog(Context context, int themeResId) {
107 this(context, themeResId, false /* cancelIsNeutral */,
108 new ZenModeDialogMetricsLogger(context));
109 }
110
111 public EnableZenModeDialog(Context context, int themeResId, boolean cancelIsNeutral,
112 ZenModeDialogMetricsLogger metricsLogger) {
113 mContext = context;
114 mThemeResId = themeResId;
115 mCancelIsNeutral = cancelIsNeutral;
116 mMetricsLogger = metricsLogger;
117 }
118
119 public AlertDialog createDialog() {
120 mNotificationManager = (NotificationManager) mContext.
121 getSystemService(Context.NOTIFICATION_SERVICE);
122 mForeverId = Condition.newId(mContext).appendPath("forever").build();
123 mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
124 mUserId = mContext.getUserId();
125 mAttached = false;
126
127 final AlertDialog.Builder builder = new AlertDialog.Builder(mContext, mThemeResId)
128 .setTitle(R.string.zen_mode_settings_turn_on_dialog_title)
129 .setPositiveButton(R.string.zen_mode_enable_dialog_turn_on,
130 new DialogInterface.OnClickListener() {
131 @Override
132 public void onClick(DialogInterface dialog, int which) {
133 int checkedId = mZenRadioGroup.getCheckedRadioButtonId();
134 ConditionTag tag = getConditionTagAt(checkedId);
135
136 if (isForever(tag.condition)) {
137 mMetricsLogger.logOnEnableZenModeForever();
138 } else if (isAlarm(tag.condition)) {
139 mMetricsLogger.logOnEnableZenModeUntilAlarm();
140 } else if (isCountdown(tag.condition)) {
141 mMetricsLogger.logOnEnableZenModeUntilCountdown();
142 } else {
143 Slog.d(TAG, "Invalid manual condition: " + tag.condition);
144 }
145 // always triggers priority-only dnd with chosen condition
146 mNotificationManager.setZenMode(
147 Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS,
148 getRealConditionId(tag.condition), TAG);
149 }
150 });
151
152 if (mCancelIsNeutral) {
153 builder.setNeutralButton(R.string.cancel, null);
154 } else {
155 builder.setNegativeButton(R.string.cancel, null);
156 }
157
158 View contentView = getContentView();
159 bindConditions(forever());
160 builder.setView(contentView);
161 return builder.create();
162 }
163
164 private void hideAllConditions() {
165 final int N = mZenRadioGroupContent.getChildCount();
166 for (int i = 0; i < N; i++) {
167 mZenRadioGroupContent.getChildAt(i).setVisibility(View.GONE);
168 }
169
170 mZenAlarmWarning.setVisibility(View.GONE);
171 }
172
173 protected View getContentView() {
174 if (mLayoutInflater == null) {
175 mLayoutInflater = new PhoneWindow(mContext).getLayoutInflater();
176 }
177 View contentView = mLayoutInflater.inflate(R.layout.zen_mode_turn_on_dialog_container,
178 null);
179 ScrollView container = (ScrollView) contentView.findViewById(R.id.container);
180
181 mZenRadioGroup = container.findViewById(R.id.zen_radio_buttons);
182 mZenRadioGroupContent = container.findViewById(R.id.zen_radio_buttons_content);
183 mZenAlarmWarning = container.findViewById(R.id.zen_alarm_warning);
184
185 for (int i = 0; i < MAX_MANUAL_DND_OPTIONS; i++) {
186 final View radioButton = mLayoutInflater.inflate(R.layout.zen_mode_radio_button,
187 mZenRadioGroup, false);
188 mZenRadioGroup.addView(radioButton);
189 radioButton.setId(i);
190
191 final View radioButtonContent = mLayoutInflater.inflate(R.layout.zen_mode_condition,
192 mZenRadioGroupContent, false);
193 radioButtonContent.setId(i + MAX_MANUAL_DND_OPTIONS);
194 mZenRadioGroupContent.addView(radioButtonContent);
195 }
196
197 hideAllConditions();
198 return contentView;
199 }
200
201 @VisibleForTesting
202 protected void bind(final Condition condition, final View row, final int rowId) {
203 if (condition == null) throw new IllegalArgumentException("condition must not be null");
204
205 final boolean enabled = condition.state == Condition.STATE_TRUE;
206 final ConditionTag tag = row.getTag() != null ? (ConditionTag) row.getTag() :
207 new ConditionTag();
208 row.setTag(tag);
209 final boolean first = tag.rb == null;
210 if (tag.rb == null) {
211 tag.rb = (RadioButton) mZenRadioGroup.getChildAt(rowId);
212 }
213 tag.condition = condition;
214 final Uri conditionId = getConditionId(tag.condition);
215 if (DEBUG) Log.d(TAG, "bind i=" + mZenRadioGroupContent.indexOfChild(row) + " first="
216 + first + " condition=" + conditionId);
217 tag.rb.setEnabled(enabled);
218 tag.rb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
219 @Override
220 public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
221 if (isChecked) {
222 tag.rb.setChecked(true);
223 if (DEBUG) Log.d(TAG, "onCheckedChanged " + conditionId);
224 mMetricsLogger.logOnConditionSelected();
225 updateAlarmWarningText(tag.condition);
226 }
227 tag.line1.setStateDescription(
228 isChecked ? buttonView.getContext().getString(
229 com.android.internal.R.string.selected) : null);
230 }
231 });
232
233 updateUi(tag, row, condition, enabled, rowId, conditionId);
234 row.setVisibility(View.VISIBLE);
235 }
236
237 @VisibleForTesting
238 protected ConditionTag getConditionTagAt(int index) {
239 return (ConditionTag) mZenRadioGroupContent.getChildAt(index).getTag();
240 }
241
242 @VisibleForTesting
243 protected void bindConditions(Condition c) {
244 // forever
245 bind(forever(), mZenRadioGroupContent.getChildAt(FOREVER_CONDITION_INDEX),
246 FOREVER_CONDITION_INDEX);
247 if (c == null) {
248 bindGenericCountdown();
249 bindNextAlarm(getTimeUntilNextAlarmCondition());
250 } else if (isForever(c)) {
251 getConditionTagAt(FOREVER_CONDITION_INDEX).rb.setChecked(true);
252 bindGenericCountdown();
253 bindNextAlarm(getTimeUntilNextAlarmCondition());
254 } else {
255 if (isAlarm(c)) {
256 bindGenericCountdown();
257 bindNextAlarm(c);
258 getConditionTagAt(COUNTDOWN_ALARM_CONDITION_INDEX).rb.setChecked(true);
259 } else if (isCountdown(c)) {
260 bindNextAlarm(getTimeUntilNextAlarmCondition());
261 bind(c, mZenRadioGroupContent.getChildAt(COUNTDOWN_CONDITION_INDEX),
262 COUNTDOWN_CONDITION_INDEX);
263 getConditionTagAt(COUNTDOWN_CONDITION_INDEX).rb.setChecked(true);
264 } else {
265 Slog.d(TAG, "Invalid manual condition: " + c);
266 }
267 }
268 }
269
270 public static Uri getConditionId(Condition condition) {
271 return condition != null ? condition.id : null;
272 }
273
274 public Condition forever() {
275 Uri foreverId = Condition.newId(mContext).appendPath("forever").build();
276 return new Condition(foreverId, foreverSummary(mContext), "", "", 0 /*icon*/,
277 Condition.STATE_TRUE, 0 /*flags*/);
278 }
279
280 public long getNextAlarm() {
281 final AlarmManager.AlarmClockInfo info = mAlarmManager.getNextAlarmClock(mUserId);
282 return info != null ? info.getTriggerTime() : 0;
283 }
284
285 @VisibleForTesting
286 protected boolean isAlarm(Condition c) {
287 return c != null && ZenModeConfig.isValidCountdownToAlarmConditionId(c.id);
288 }
289
290 @VisibleForTesting
291 protected boolean isCountdown(Condition c) {
292 return c != null && ZenModeConfig.isValidCountdownConditionId(c.id);
293 }
294
295 private boolean isForever(Condition c) {
296 return c != null && mForeverId.equals(c.id);
297 }
298
299 private Uri getRealConditionId(Condition condition) {
300 return isForever(condition) ? null : getConditionId(condition);
301 }
302
303 private String foreverSummary(Context context) {
304 return context.getString(com.android.internal.R.string.zen_mode_forever);
305 }
306
307 private static void setToMidnight(Calendar calendar) {
308 calendar.set(Calendar.HOUR_OF_DAY, 0);
309 calendar.set(Calendar.MINUTE, 0);
310 calendar.set(Calendar.SECOND, 0);
311 calendar.set(Calendar.MILLISECOND, 0);
312 }
313
314 // Returns a time condition if the next alarm is within the next week.
315 @VisibleForTesting
316 protected Condition getTimeUntilNextAlarmCondition() {
317 GregorianCalendar weekRange = new GregorianCalendar();
318 setToMidnight(weekRange);
319 weekRange.add(Calendar.DATE, 6);
320 final long nextAlarmMs = getNextAlarm();
321 if (nextAlarmMs > 0) {
322 GregorianCalendar nextAlarm = new GregorianCalendar();
323 nextAlarm.setTimeInMillis(nextAlarmMs);
324 setToMidnight(nextAlarm);
325
326 if (weekRange.compareTo(nextAlarm) >= 0) {
327 return ZenModeConfig.toNextAlarmCondition(mContext, nextAlarmMs,
328 ActivityManager.getCurrentUser());
329 }
330 }
331 return null;
332 }
333
334 @VisibleForTesting
335 protected void bindGenericCountdown() {
336 mBucketIndex = DEFAULT_BUCKET_INDEX;
337 Condition countdown = ZenModeConfig.toTimeCondition(mContext,
338 MINUTE_BUCKETS[mBucketIndex], ActivityManager.getCurrentUser());
339 if (!mAttached || getConditionTagAt(COUNTDOWN_CONDITION_INDEX).condition == null) {
340 bind(countdown, mZenRadioGroupContent.getChildAt(COUNTDOWN_CONDITION_INDEX),
341 COUNTDOWN_CONDITION_INDEX);
342 }
343 }
344
345 private void updateUi(ConditionTag tag, View row, Condition condition,
346 boolean enabled, int rowId, Uri conditionId) {
347 if (tag.lines == null) {
348 tag.lines = row.findViewById(android.R.id.content);
349 }
350 if (tag.line1 == null) {
351 tag.line1 = (TextView) row.findViewById(android.R.id.text1);
352 }
353
354 if (tag.line2 == null) {
355 tag.line2 = (TextView) row.findViewById(android.R.id.text2);
356 }
357
358 final String line1 = !TextUtils.isEmpty(condition.line1) ? condition.line1
359 : condition.summary;
360 final String line2 = condition.line2;
361 tag.line1.setText(line1);
362 if (TextUtils.isEmpty(line2)) {
363 tag.line2.setVisibility(View.GONE);
364 } else {
365 tag.line2.setVisibility(View.VISIBLE);
366 tag.line2.setText(line2);
367 }
368 tag.lines.setEnabled(enabled);
369 tag.lines.setAlpha(enabled ? 1 : .4f);
370
371 tag.lines.setOnClickListener(new View.OnClickListener() {
372 @Override
373 public void onClick(View v) {
374 tag.rb.setChecked(true);
375 }
376 });
377
378 final long time = ZenModeConfig.tryParseCountdownConditionId(conditionId);
379 final ImageView minusButton = (ImageView) row.findViewById(android.R.id.button1);
380 final ImageView plusButton = (ImageView) row.findViewById(android.R.id.button2);
381 if (rowId == COUNTDOWN_CONDITION_INDEX && time > 0) {
382 minusButton.setOnClickListener(new View.OnClickListener() {
383 @Override
384 public void onClick(View v) {
385 onClickTimeButton(row, tag, false /*down*/, rowId);
386 tag.lines.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
387 }
388 });
389
390 plusButton.setOnClickListener(new View.OnClickListener() {
391 @Override
392 public void onClick(View v) {
393 onClickTimeButton(row, tag, true /*up*/, rowId);
394 tag.lines.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
395 }
396 });
397 if (mBucketIndex > -1) {
398 minusButton.setEnabled(mBucketIndex > 0);
399 plusButton.setEnabled(mBucketIndex < MINUTE_BUCKETS.length - 1);
400 } else {
401 final long span = time - System.currentTimeMillis();
402 minusButton.setEnabled(span > MIN_BUCKET_MINUTES * MINUTES_MS);
403 final Condition maxCondition = ZenModeConfig.toTimeCondition(mContext,
404 MAX_BUCKET_MINUTES, ActivityManager.getCurrentUser());
405 plusButton.setEnabled(!Objects.equals(condition.summary, maxCondition.summary));
406 }
407
408 minusButton.setAlpha(minusButton.isEnabled() ? 1f : .5f);
409 plusButton.setAlpha(plusButton.isEnabled() ? 1f : .5f);
410 } else {
411 if (minusButton != null) {
412 ((ViewGroup) row).removeView(minusButton);
413 }
414
415 if (plusButton != null) {
416 ((ViewGroup) row).removeView(plusButton);
417 }
418 }
419 }
420
421 @VisibleForTesting
422 protected void bindNextAlarm(Condition c) {
423 View alarmContent = mZenRadioGroupContent.getChildAt(COUNTDOWN_ALARM_CONDITION_INDEX);
424 ConditionTag tag = (ConditionTag) alarmContent.getTag();
425
426 if (c != null && (!mAttached || tag == null || tag.condition == null)) {
427 bind(c, alarmContent, COUNTDOWN_ALARM_CONDITION_INDEX);
428 }
429
430 // hide the alarm radio button if there isn't a "next alarm condition"
431 tag = (ConditionTag) alarmContent.getTag();
432 boolean showAlarm = tag != null && tag.condition != null;
433 mZenRadioGroup.getChildAt(COUNTDOWN_ALARM_CONDITION_INDEX).setVisibility(
434 showAlarm ? View.VISIBLE : View.GONE);
435 alarmContent.setVisibility(showAlarm ? View.VISIBLE : View.GONE);
436 }
437
438 private void onClickTimeButton(View row, ConditionTag tag, boolean up, int rowId) {
439 mMetricsLogger.logOnClickTimeButton(up);
440 Condition newCondition = null;
441 final int N = MINUTE_BUCKETS.length;
442 if (mBucketIndex == -1) {
443 // not on a known index, search for the next or prev bucket by time
444 final Uri conditionId = getConditionId(tag.condition);
445 final long time = ZenModeConfig.tryParseCountdownConditionId(conditionId);
446 final long now = System.currentTimeMillis();
447 for (int i = 0; i < N; i++) {
448 int j = up ? i : N - 1 - i;
449 final int bucketMinutes = MINUTE_BUCKETS[j];
450 final long bucketTime = now + bucketMinutes * MINUTES_MS;
451 if (up && bucketTime > time || !up && bucketTime < time) {
452 mBucketIndex = j;
453 newCondition = ZenModeConfig.toTimeCondition(mContext,
454 bucketTime, bucketMinutes, ActivityManager.getCurrentUser(),
455 false /*shortVersion*/);
456 break;
457 }
458 }
459 if (newCondition == null) {
460 mBucketIndex = DEFAULT_BUCKET_INDEX;
461 newCondition = ZenModeConfig.toTimeCondition(mContext,
462 MINUTE_BUCKETS[mBucketIndex], ActivityManager.getCurrentUser());
463 }
464 } else {
465 // on a known index, simply increment or decrement
466 mBucketIndex = Math.max(0, Math.min(N - 1, mBucketIndex + (up ? 1 : -1)));
467 newCondition = ZenModeConfig.toTimeCondition(mContext,
468 MINUTE_BUCKETS[mBucketIndex], ActivityManager.getCurrentUser());
469 }
470 bind(newCondition, row, rowId);
471 updateAlarmWarningText(tag.condition);
472 tag.rb.setChecked(true);
473 }
474
475 private void updateAlarmWarningText(Condition condition) {
476 String warningText = computeAlarmWarningText(condition);
477 mZenAlarmWarning.setText(warningText);
478 mZenAlarmWarning.setVisibility(warningText == null ? View.GONE : View.VISIBLE);
479 }
480
481 @VisibleForTesting
482 protected String computeAlarmWarningText(Condition condition) {
483 boolean allowAlarms = (mNotificationManager.getNotificationPolicy().priorityCategories
484 & NotificationManager.Policy.PRIORITY_CATEGORY_ALARMS) != 0;
485
486 // don't show alarm warning if alarms are allowed to bypass dnd
487 if (allowAlarms) {
488 return null;
489 }
490
491 final long now = System.currentTimeMillis();
492 final long nextAlarm = getNextAlarm();
493 if (nextAlarm < now) {
494 return null;
495 }
496 int warningRes = 0;
497 if (condition == null || isForever(condition)) {
498 warningRes = R.string.zen_alarm_warning_indef;
499 } else {
500 final long time = ZenModeConfig.tryParseCountdownConditionId(condition.id);
501 if (time > now && nextAlarm < time) {
502 warningRes = R.string.zen_alarm_warning;
503 }
504 }
505 if (warningRes == 0) {
506 return null;
507 }
508
509 return mContext.getResources().getString(warningRes, getTime(nextAlarm, now));
510 }
511
512 @VisibleForTesting
513 protected String getTime(long nextAlarm, long now) {
514 final boolean soon = (nextAlarm - now) < 24 * 60 * 60 * 1000;
515 final boolean is24 = DateFormat.is24HourFormat(mContext, ActivityManager.getCurrentUser());
516 final String skeleton = soon ? (is24 ? "Hm" : "hma") : (is24 ? "EEEHm" : "EEEhma");
517 final String pattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton);
518 final CharSequence formattedTime = DateFormat.format(pattern, nextAlarm);
519 final int templateRes = soon ? R.string.alarm_template : R.string.alarm_template_far;
520 return mContext.getResources().getString(templateRes, formattedTime);
521 }
522
523 // used as the view tag on condition rows
524 @VisibleForTesting
525 protected static class ConditionTag {
526 public RadioButton rb;
527 public View lines;
528 public TextView line1;
529 public TextView line2;
530 public Condition condition;
531 }
532}