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