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