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