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