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