1 /* 2 * Copyright (C) 2017 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.systemui.statusbar.notification.row; 18 19 import static com.android.systemui.util.PluralMessageFormaterKt.icuMessageFormat; 20 21 import android.animation.Animator; 22 import android.animation.AnimatorListenerAdapter; 23 import android.animation.AnimatorSet; 24 import android.animation.ObjectAnimator; 25 import android.content.Context; 26 import android.content.res.Resources; 27 import android.graphics.Typeface; 28 import android.metrics.LogMaker; 29 import android.os.Bundle; 30 import android.provider.Settings; 31 import android.service.notification.SnoozeCriterion; 32 import android.service.notification.StatusBarNotification; 33 import android.text.SpannableString; 34 import android.text.style.StyleSpan; 35 import android.util.AttributeSet; 36 import android.util.KeyValueListParser; 37 import android.util.Log; 38 import android.view.LayoutInflater; 39 import android.view.View; 40 import android.view.ViewGroup; 41 import android.view.accessibility.AccessibilityEvent; 42 import android.view.accessibility.AccessibilityNodeInfo; 43 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 44 import android.widget.ImageView; 45 import android.widget.LinearLayout; 46 import android.widget.TextView; 47 48 import androidx.annotation.NonNull; 49 50 import com.android.app.animation.Interpolators; 51 import com.android.internal.annotations.VisibleForTesting; 52 import com.android.internal.logging.MetricsLogger; 53 import com.android.internal.logging.nano.MetricsProto.MetricsEvent; 54 import com.android.systemui.plugins.statusbar.NotificationSwipeActionHelper; 55 import com.android.systemui.plugins.statusbar.NotificationSwipeActionHelper.SnoozeOption; 56 import com.android.systemui.res.R; 57 58 import java.util.ArrayList; 59 import java.util.List; 60 import java.util.concurrent.TimeUnit; 61 62 public class NotificationSnooze extends LinearLayout 63 implements NotificationGuts.GutsContent, View.OnClickListener { 64 65 private static final String TAG = "NotificationSnooze"; 66 /** 67 * If this changes more number increases, more assistant action resId's should be defined for 68 * accessibility purposes, see {@link #setSnoozeOptions(List)} 69 */ 70 private static final int MAX_ASSISTANT_SUGGESTIONS = 1; 71 private static final String KEY_DEFAULT_SNOOZE = "default"; 72 private static final String KEY_OPTIONS = "options_array"; 73 private static final LogMaker OPTIONS_OPEN_LOG = 74 new LogMaker(MetricsEvent.NOTIFICATION_SNOOZE_OPTIONS) 75 .setType(MetricsEvent.TYPE_OPEN); 76 private static final LogMaker OPTIONS_CLOSE_LOG = 77 new LogMaker(MetricsEvent.NOTIFICATION_SNOOZE_OPTIONS) 78 .setType(MetricsEvent.TYPE_CLOSE); 79 private static final LogMaker UNDO_LOG = 80 new LogMaker(MetricsEvent.NOTIFICATION_UNDO_SNOOZE) 81 .setType(MetricsEvent.TYPE_ACTION); 82 83 private static final String PARAGRAPH_SEPARATOR = "\u2029"; 84 85 private NotificationGuts mGutsContainer; 86 private NotificationSwipeActionHelper mSnoozeListener; 87 private StatusBarNotification mSbn; 88 89 @VisibleForTesting 90 public View mSnoozeView; 91 @VisibleForTesting 92 public TextView mSelectedOptionText; 93 private TextView mUndoButton; 94 @VisibleForTesting 95 public ImageView mExpandButton; 96 @VisibleForTesting 97 public View mDivider; 98 @VisibleForTesting 99 public ViewGroup mSnoozeOptionContainer; 100 @VisibleForTesting 101 public List<SnoozeOption> mSnoozeOptions; 102 private int mCollapsedHeight; 103 private SnoozeOption mDefaultOption; 104 @VisibleForTesting 105 public SnoozeOption mSelectedOption; 106 private boolean mSnoozing; 107 @VisibleForTesting 108 public boolean mExpanded; 109 private AnimatorSet mExpandAnimation; 110 private KeyValueListParser mParser; 111 112 private final static int[] sAccessibilityActions = { 113 R.id.action_snooze_shorter, 114 R.id.action_snooze_short, 115 R.id.action_snooze_long, 116 R.id.action_snooze_longer, 117 }; 118 119 private MetricsLogger mMetricsLogger = new MetricsLogger(); 120 NotificationSnooze(Context context, AttributeSet attrs)121 public NotificationSnooze(Context context, AttributeSet attrs) { 122 super(context, attrs); 123 mParser = new KeyValueListParser(','); 124 } 125 126 @VisibleForTesting getDefaultOption()127 SnoozeOption getDefaultOption() { 128 return mDefaultOption; 129 } 130 131 @VisibleForTesting setKeyValueListParser(KeyValueListParser parser)132 void setKeyValueListParser(KeyValueListParser parser) { 133 mParser = parser; 134 } 135 136 @Override onFinishInflate()137 protected void onFinishInflate() { 138 super.onFinishInflate(); 139 mCollapsedHeight = getResources().getDimensionPixelSize(R.dimen.snooze_snackbar_min_height); 140 mSnoozeView = findViewById(R.id.notification_snooze); 141 mSnoozeView.setOnClickListener(this); 142 mSelectedOptionText = (TextView) findViewById(R.id.snooze_option_default); 143 mUndoButton = (TextView) findViewById(R.id.undo); 144 mUndoButton.setOnClickListener(this); 145 mUndoButton.setContentDescription( 146 getContext().getString(R.string.snooze_undo_content_description)); 147 mExpandButton = (ImageView) findViewById(R.id.expand_button); 148 mDivider = findViewById(R.id.divider); 149 mDivider.setAlpha(0f); 150 mSnoozeOptionContainer = (ViewGroup) findViewById(R.id.snooze_options); 151 mSnoozeOptionContainer.setVisibility(View.INVISIBLE); 152 mSnoozeOptionContainer.setAlpha(0f); 153 154 // Create the different options based on list 155 mSnoozeOptions = getDefaultSnoozeOptions(); 156 createOptionViews(); 157 158 setSelected(mDefaultOption, false); 159 } 160 161 @Override onAttachedToWindow()162 protected void onAttachedToWindow() { 163 super.onAttachedToWindow(); 164 logOptionSelection(MetricsEvent.NOTIFICATION_SNOOZE_CLICKED, mDefaultOption); 165 dispatchConfigurationChanged(getResources().getConfiguration()); 166 } 167 168 @Override onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)169 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { 170 super.onInitializeAccessibilityNodeInfo(info); 171 info.addAction(new AccessibilityAction(R.id.action_snooze_undo, 172 getResources().getString(R.string.snooze_undo))); 173 int count = mSnoozeOptions.size(); 174 for (int i = 0; i < count; i++) { 175 AccessibilityAction action = mSnoozeOptions.get(i).getAccessibilityAction(); 176 if (action != null) { 177 info.addAction(action); 178 } 179 } 180 181 mSnoozeView.setAccessibilityDelegate(new AccessibilityDelegate() { 182 @Override 183 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 184 super.onInitializeAccessibilityNodeInfo(host, info); 185 // Replace "Double tap to activate" prompt with "Double tap to expand/collapse" 186 AccessibilityAction customClick = new AccessibilityAction( 187 AccessibilityNodeInfo.ACTION_CLICK, getExpandActionString()); 188 info.addAction(customClick); 189 } 190 }); 191 } 192 193 /** 194 * Update the content description of the snooze view based on the snooze option and whether the 195 * snooze options are expanded or not. 196 * For example, this will be something like "Collapsed\u2029Snooze for 1 hour". The paragraph 197 * separator is added to introduce a break in speech, to match what TalkBack does by default 198 * when you e.g. press on a notification. 199 */ updateContentDescription()200 private void updateContentDescription() { 201 mSnoozeView.setContentDescription( 202 getExpandStateString() + PARAGRAPH_SEPARATOR + mSelectedOptionText.getText()); 203 } 204 205 /** Returns "collapse" if the snooze options are expanded, or "expand" otherwise. */ 206 @NonNull getExpandActionString()207 private String getExpandActionString() { 208 return mContext.getString(mExpanded 209 ? com.android.internal.R.string.expand_button_content_description_expanded 210 : com.android.internal.R.string.expand_button_content_description_collapsed); 211 } 212 213 214 /** Returns "expanded" if the snooze options are expanded, or "collapsed" otherwise. */ 215 @NonNull getExpandStateString()216 private String getExpandStateString() { 217 return mContext.getString( 218 (mExpanded ? com.android.internal.R.string.content_description_expanded 219 : com.android.internal.R.string.content_description_collapsed)); 220 } 221 222 @Override performAccessibilityActionInternal(int action, Bundle arguments)223 public boolean performAccessibilityActionInternal(int action, Bundle arguments) { 224 if (super.performAccessibilityActionInternal(action, arguments)) { 225 return true; 226 } 227 if (action == R.id.action_snooze_undo) { 228 undoSnooze(mUndoButton); 229 return true; 230 } 231 for (int i = 0; i < mSnoozeOptions.size(); i++) { 232 SnoozeOption so = mSnoozeOptions.get(i); 233 if (so.getAccessibilityAction() != null 234 && so.getAccessibilityAction().getId() == action) { 235 setSelected(so, true); 236 mSnoozeView.sendAccessibilityEvent( 237 AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); 238 return true; 239 } 240 } 241 return false; 242 } 243 setSnoozeOptions(final List<SnoozeCriterion> snoozeList)244 public void setSnoozeOptions(final List<SnoozeCriterion> snoozeList) { 245 if (snoozeList == null) { 246 return; 247 } 248 mSnoozeOptions.clear(); 249 mSnoozeOptions = getDefaultSnoozeOptions(); 250 final int count = Math.min(MAX_ASSISTANT_SUGGESTIONS, snoozeList.size()); 251 for (int i = 0; i < count; i++) { 252 SnoozeCriterion sc = snoozeList.get(i); 253 AccessibilityAction action = new AccessibilityAction( 254 R.id.action_snooze_assistant_suggestion_1, sc.getExplanation()); 255 mSnoozeOptions.add(new NotificationSnoozeOption(sc, 0, sc.getExplanation(), 256 sc.getConfirmation(), action)); 257 } 258 createOptionViews(); 259 } 260 isExpanded()261 public boolean isExpanded() { 262 return mExpanded; 263 } 264 setSnoozeListener(NotificationSwipeActionHelper listener)265 public void setSnoozeListener(NotificationSwipeActionHelper listener) { 266 mSnoozeListener = listener; 267 } 268 setStatusBarNotification(StatusBarNotification sbn)269 public void setStatusBarNotification(StatusBarNotification sbn) { 270 mSbn = sbn; 271 } 272 273 @VisibleForTesting getDefaultSnoozeOptions()274 ArrayList<SnoozeOption> getDefaultSnoozeOptions() { 275 final Resources resources = getContext().getResources(); 276 ArrayList<SnoozeOption> options = new ArrayList<>(); 277 try { 278 final String config = Settings.Global.getString(getContext().getContentResolver(), 279 Settings.Global.NOTIFICATION_SNOOZE_OPTIONS); 280 mParser.setString(config); 281 } catch (IllegalArgumentException e) { 282 Log.e(TAG, "Bad snooze constants"); 283 } 284 285 final int defaultSnooze = mParser.getInt(KEY_DEFAULT_SNOOZE, 286 resources.getInteger(R.integer.config_notification_snooze_time_default)); 287 final int[] snoozeTimes = mParser.getIntArray(KEY_OPTIONS, 288 resources.getIntArray(R.array.config_notification_snooze_times)); 289 290 for (int i = 0; i < snoozeTimes.length && i < sAccessibilityActions.length; i++) { 291 int snoozeTime = snoozeTimes[i]; 292 SnoozeOption option = createOption(snoozeTime, sAccessibilityActions[i]); 293 if (i == 0 || snoozeTime == defaultSnooze) { 294 mDefaultOption = option; 295 } 296 options.add(option); 297 } 298 return options; 299 } 300 createOption(int minutes, int accessibilityActionId)301 private SnoozeOption createOption(int minutes, int accessibilityActionId) { 302 Resources res = getResources(); 303 boolean showInHours = minutes >= 60; 304 int stringResId = showInHours 305 ? R.string.snoozeHourOptions 306 : R.string.snoozeMinuteOptions; 307 int count = showInHours ? (minutes / 60) : minutes; 308 String description = icuMessageFormat(res, stringResId, count); 309 String resultText = String.format(res.getString(R.string.snoozed_for_time), description); 310 AccessibilityAction action = new AccessibilityAction(accessibilityActionId, description); 311 final int index = resultText.indexOf(description); 312 if (index == -1) { 313 return new NotificationSnoozeOption(null, minutes, description, resultText, action); 314 } 315 SpannableString string = new SpannableString(resultText); 316 string.setSpan(new StyleSpan(Typeface.BOLD, res.getConfiguration().fontWeightAdjustment), 317 index, index + description.length(), 0 /* flags */); 318 return new NotificationSnoozeOption(null, minutes, description, string, 319 action); 320 } 321 createOptionViews()322 private void createOptionViews() { 323 mSnoozeOptionContainer.removeAllViews(); 324 LayoutInflater inflater = (LayoutInflater) getContext().getSystemService( 325 Context.LAYOUT_INFLATER_SERVICE); 326 for (int i = 0; i < mSnoozeOptions.size(); i++) { 327 SnoozeOption option = mSnoozeOptions.get(i); 328 TextView tv = (TextView) inflater.inflate(R.layout.notification_snooze_option, 329 mSnoozeOptionContainer, false); 330 mSnoozeOptionContainer.addView(tv); 331 tv.setText(option.getDescription()); 332 tv.setTag(option); 333 tv.setOnClickListener(this); 334 } 335 } 336 hideSelectedOption()337 private void hideSelectedOption() { 338 final int childCount = mSnoozeOptionContainer.getChildCount(); 339 for (int i = 0; i < childCount; i++) { 340 final View child = mSnoozeOptionContainer.getChildAt(i); 341 child.setVisibility(child.getTag() == mSelectedOption ? View.GONE : View.VISIBLE); 342 } 343 } 344 345 @VisibleForTesting showSnoozeOptions(boolean show)346 public void showSnoozeOptions(boolean show) { 347 int drawableId = show ? com.android.internal.R.drawable.ic_collapse_notification 348 : com.android.internal.R.drawable.ic_expand_notification; 349 mExpandButton.setImageResource(drawableId); 350 mExpandButton.setContentDescription(getExpandActionString()); 351 if (mExpanded != show) { 352 mExpanded = show; 353 updateContentDescription(); 354 animateSnoozeOptions(show); 355 if (mGutsContainer != null) { 356 mGutsContainer.onHeightChanged(); 357 } 358 } 359 } 360 animateSnoozeOptions(boolean show)361 private void animateSnoozeOptions(boolean show) { 362 if (mExpandAnimation != null) { 363 mExpandAnimation.cancel(); 364 } 365 ObjectAnimator dividerAnim = ObjectAnimator.ofFloat(mDivider, View.ALPHA, 366 mDivider.getAlpha(), show ? 1f : 0f); 367 ObjectAnimator optionAnim = ObjectAnimator.ofFloat(mSnoozeOptionContainer, View.ALPHA, 368 mSnoozeOptionContainer.getAlpha(), show ? 1f : 0f); 369 mSnoozeOptionContainer.setVisibility(View.VISIBLE); 370 mExpandAnimation = new AnimatorSet(); 371 mExpandAnimation.playTogether(dividerAnim, optionAnim); 372 mExpandAnimation.setDuration(150); 373 mExpandAnimation.setInterpolator(show ? Interpolators.ALPHA_IN : Interpolators.ALPHA_OUT); 374 mExpandAnimation.addListener(new AnimatorListenerAdapter() { 375 boolean cancelled = false; 376 377 @Override 378 public void onAnimationCancel(Animator animation) { 379 cancelled = true; 380 } 381 382 @Override 383 public void onAnimationEnd(Animator animation) { 384 if (!show && !cancelled) { 385 mSnoozeOptionContainer.setVisibility(View.INVISIBLE); 386 mSnoozeOptionContainer.setAlpha(0f); 387 } 388 } 389 }); 390 mExpandAnimation.start(); 391 } 392 393 @VisibleForTesting setSelected(SnoozeOption option, boolean userAction)394 public void setSelected(SnoozeOption option, boolean userAction) { 395 if (option != mSelectedOption) { 396 mSelectedOption = option; 397 mSelectedOptionText.setText(option.getConfirmation()); 398 updateContentDescription(); 399 } 400 showSnoozeOptions(false); 401 hideSelectedOption(); 402 if (userAction) { 403 mSnoozeView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); 404 logOptionSelection(MetricsEvent.NOTIFICATION_SELECT_SNOOZE, option); 405 } 406 } 407 408 @Override requestAccessibilityFocus()409 public boolean requestAccessibilityFocus() { 410 if (mExpanded) { 411 return super.requestAccessibilityFocus(); 412 } else { 413 mSnoozeView.requestAccessibilityFocus(); 414 return false; 415 } 416 } 417 logOptionSelection(int category, SnoozeOption option)418 private void logOptionSelection(int category, SnoozeOption option) { 419 int index = mSnoozeOptions.indexOf(option); 420 long duration = TimeUnit.MINUTES.toMillis(option.getMinutesToSnoozeFor()); 421 mMetricsLogger.write(new LogMaker(category) 422 .setType(MetricsEvent.TYPE_ACTION) 423 .addTaggedData(MetricsEvent.FIELD_NOTIFICATION_SNOOZE_INDEX, index) 424 .addTaggedData(MetricsEvent.FIELD_NOTIFICATION_SNOOZE_DURATION_MS, duration)); 425 } 426 427 @Override onClick(View v)428 public void onClick(View v) { 429 if (mGutsContainer != null) { 430 mGutsContainer.resetFalsingCheck(); 431 } 432 final int id = v.getId(); 433 final SnoozeOption tag = (SnoozeOption) v.getTag(); 434 if (tag != null) { 435 setSelected(tag, true); 436 } else if (id == R.id.notification_snooze) { 437 // Toggle snooze options 438 showSnoozeOptions(!mExpanded); 439 mSnoozeView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); 440 mMetricsLogger.write(!mExpanded ? OPTIONS_OPEN_LOG : OPTIONS_CLOSE_LOG); 441 } else { 442 // Undo snooze was selected 443 undoSnooze(v); 444 mMetricsLogger.write(UNDO_LOG); 445 } 446 } 447 undoSnooze(View v)448 private void undoSnooze(View v) { 449 mSelectedOption = null; 450 showSnoozeOptions(false); 451 mGutsContainer.closeControls(v, /* save= */ false); 452 } 453 454 @Override getActualHeight()455 public int getActualHeight() { 456 return mExpanded ? getHeight() : mCollapsedHeight; 457 } 458 459 @Override willBeRemoved()460 public boolean willBeRemoved() { 461 return mSnoozing; 462 } 463 464 @Override getContentView()465 public View getContentView() { 466 // Reset the view before use 467 setSelected(mDefaultOption, false); 468 showSnoozeOptions(false); 469 return this; 470 } 471 472 @Override setGutsParent(NotificationGuts guts)473 public void setGutsParent(NotificationGuts guts) { 474 mGutsContainer = guts; 475 } 476 477 @Override handleCloseControls(boolean save, boolean force)478 public boolean handleCloseControls(boolean save, boolean force) { 479 if (!save) { 480 // Undo changes and let the guts handle closing the view 481 mSelectedOption = null; 482 showSnoozeOptions(false); 483 return false; 484 } else if (mExpanded && !force) { 485 // Collapse expanded state on outside touch 486 showSnoozeOptions(false); 487 return true; 488 } else if (mSnoozeListener != null && mSelectedOption != null) { 489 // Snooze option selected so commit it 490 mSnoozing = true; 491 mSnoozeListener.snooze(mSbn, mSelectedOption); 492 return true; 493 } else { 494 // The view should actually be closed 495 setSelected(mSnoozeOptions.get(0), false); 496 return false; // Return false here so that guts handles closing the view 497 } 498 } 499 500 @Override isLeavebehind()501 public boolean isLeavebehind() { 502 return true; 503 } 504 505 @Override shouldBeSavedOnClose()506 public boolean shouldBeSavedOnClose() { 507 return true; 508 } 509 510 @Override needsFalsingProtection()511 public boolean needsFalsingProtection() { 512 return false; 513 } 514 515 public class NotificationSnoozeOption implements SnoozeOption { 516 private SnoozeCriterion mCriterion; 517 private int mMinutesToSnoozeFor; 518 private CharSequence mDescription; 519 private CharSequence mConfirmation; 520 private AccessibilityAction mAction; 521 NotificationSnoozeOption(SnoozeCriterion sc, int minToSnoozeFor, CharSequence description, CharSequence confirmation, AccessibilityAction action)522 public NotificationSnoozeOption(SnoozeCriterion sc, int minToSnoozeFor, 523 CharSequence description, 524 CharSequence confirmation, AccessibilityAction action) { 525 mCriterion = sc; 526 mMinutesToSnoozeFor = minToSnoozeFor; 527 mDescription = description; 528 mConfirmation = confirmation; 529 mAction = action; 530 } 531 532 @Override getSnoozeCriterion()533 public SnoozeCriterion getSnoozeCriterion() { 534 return mCriterion; 535 } 536 537 @Override getDescription()538 public CharSequence getDescription() { 539 return mDescription; 540 } 541 542 @Override getConfirmation()543 public CharSequence getConfirmation() { 544 return mConfirmation; 545 } 546 547 @Override getMinutesToSnoozeFor()548 public int getMinutesToSnoozeFor() { 549 return mMinutesToSnoozeFor; 550 } 551 552 @Override getAccessibilityAction()553 public AccessibilityAction getAccessibilityAction() { 554 return mAction; 555 } 556 557 } 558 } 559