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