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