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