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