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