1 /*
2  * Copyright (C) 2019 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 package com.android.systemui.bubbles;
17 
18 import static android.os.AsyncTask.Status.FINISHED;
19 import static android.view.Display.INVALID_DISPLAY;
20 
21 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
22 
23 import android.annotation.DimenRes;
24 import android.annotation.NonNull;
25 import android.annotation.Nullable;
26 import android.app.Notification;
27 import android.app.PendingIntent;
28 import android.content.Context;
29 import android.content.Intent;
30 import android.content.pm.ApplicationInfo;
31 import android.content.pm.PackageManager;
32 import android.content.pm.ShortcutInfo;
33 import android.content.res.Resources;
34 import android.graphics.Bitmap;
35 import android.graphics.Path;
36 import android.graphics.drawable.Drawable;
37 import android.graphics.drawable.Icon;
38 import android.os.UserHandle;
39 import android.provider.Settings;
40 import android.util.Log;
41 
42 import com.android.internal.annotations.VisibleForTesting;
43 import com.android.internal.logging.InstanceId;
44 import com.android.systemui.shared.system.SysUiStatsLog;
45 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
46 
47 import java.io.FileDescriptor;
48 import java.io.PrintWriter;
49 import java.util.Objects;
50 
51 /**
52  * Encapsulates the data and UI elements of a bubble.
53  */
54 class Bubble implements BubbleViewProvider {
55     private static final String TAG = "Bubble";
56 
57     private final String mKey;
58 
59     private long mLastUpdated;
60     private long mLastAccessed;
61 
62     @Nullable
63     private BubbleController.NotificationSuppressionChangedListener mSuppressionListener;
64 
65     /** Whether the bubble should show a dot for the notification indicating updated content. */
66     private boolean mShowBubbleUpdateDot = true;
67 
68     /** Whether flyout text should be suppressed, regardless of any other flags or state. */
69     private boolean mSuppressFlyout;
70 
71     // Items that are typically loaded later
72     private String mAppName;
73     private ShortcutInfo mShortcutInfo;
74     private String mMetadataShortcutId;
75     private BadgedImageView mIconView;
76     private BubbleExpandedView mExpandedView;
77 
78     private BubbleViewInfoTask mInflationTask;
79     private boolean mInflateSynchronously;
80     private boolean mPendingIntentCanceled;
81     private boolean mIsImportantConversation;
82 
83     /**
84      * Presentational info about the flyout.
85      */
86     public static class FlyoutMessage {
87         @Nullable public Icon senderIcon;
88         @Nullable public Drawable senderAvatar;
89         @Nullable public CharSequence senderName;
90         @Nullable public CharSequence message;
91         @Nullable public boolean isGroupChat;
92     }
93 
94     private FlyoutMessage mFlyoutMessage;
95     private Drawable mBadgedAppIcon;
96     private Bitmap mBadgedImage;
97     private int mDotColor;
98     private Path mDotPath;
99     private int mFlags;
100 
101     @NonNull
102     private UserHandle mUser;
103     @NonNull
104     private String mPackageName;
105     @Nullable
106     private String mTitle;
107     @Nullable
108     private Icon mIcon;
109     private boolean mIsBubble;
110     private boolean mIsVisuallyInterruptive;
111     private boolean mIsClearable;
112     private boolean mShouldSuppressNotificationDot;
113     private boolean mShouldSuppressNotificationList;
114     private boolean mShouldSuppressPeek;
115     private int mDesiredHeight;
116     @DimenRes
117     private int mDesiredHeightResId;
118 
119     /** for logging **/
120     @Nullable
121     private InstanceId mInstanceId;
122     @Nullable
123     private String mChannelId;
124     private int mNotificationId;
125     private int mAppUid = -1;
126 
127     /**
128      * A bubble is created and can be updated. This intent is updated until the user first
129      * expands the bubble. Once the user has expanded the contents, we ignore the intent updates
130      * to prevent restarting the intent & possibly altering UI state in the activity in front of
131      * the user.
132      *
133      * Once the bubble is overflowed, the activity is finished and updates to the
134      * notification are respected. Typically an update to an overflowed bubble would result in
135      * that bubble being added back to the stack anyways.
136      */
137     @Nullable
138     private PendingIntent mIntent;
139     private boolean mIntentActive;
140     @Nullable
141     private PendingIntent.CancelListener mIntentCancelListener;
142 
143     /**
144      * Sent when the bubble & notification are no longer visible to the user (i.e. no
145      * notification in the shade, no bubble in the stack or overflow).
146      */
147     @Nullable
148     private PendingIntent mDeleteIntent;
149 
150     /**
151      * Create a bubble with limited information based on given {@link ShortcutInfo}.
152      * Note: Currently this is only being used when the bubble is persisted to disk.
153      */
Bubble(@onNull final String key, @NonNull final ShortcutInfo shortcutInfo, final int desiredHeight, final int desiredHeightResId, @Nullable final String title)154     Bubble(@NonNull final String key, @NonNull final ShortcutInfo shortcutInfo,
155             final int desiredHeight, final int desiredHeightResId, @Nullable final String title) {
156         Objects.requireNonNull(key);
157         Objects.requireNonNull(shortcutInfo);
158         mMetadataShortcutId = shortcutInfo.getId();
159         mShortcutInfo = shortcutInfo;
160         mKey = key;
161         mFlags = 0;
162         mUser = shortcutInfo.getUserHandle();
163         mPackageName = shortcutInfo.getPackage();
164         mIcon = shortcutInfo.getIcon();
165         mDesiredHeight = desiredHeight;
166         mDesiredHeightResId = desiredHeightResId;
167         mTitle = title;
168         mShowBubbleUpdateDot = false;
169     }
170 
171     @VisibleForTesting(visibility = PRIVATE)
Bubble(@onNull final NotificationEntry e, @Nullable final BubbleController.NotificationSuppressionChangedListener listener, final BubbleController.PendingIntentCanceledListener intentCancelListener)172     Bubble(@NonNull final NotificationEntry e,
173             @Nullable final BubbleController.NotificationSuppressionChangedListener listener,
174             final BubbleController.PendingIntentCanceledListener intentCancelListener) {
175         Objects.requireNonNull(e);
176         mKey = e.getKey();
177         mSuppressionListener = listener;
178         mIntentCancelListener = intent -> {
179             if (mIntent != null) {
180                 mIntent.unregisterCancelListener(mIntentCancelListener);
181             }
182             intentCancelListener.onPendingIntentCanceled(this);
183         };
184         setEntry(e);
185     }
186 
187     @Override
getKey()188     public String getKey() {
189         return mKey;
190     }
191 
getUser()192     public UserHandle getUser() {
193         return mUser;
194     }
195 
196     @NonNull
getPackageName()197     public String getPackageName() {
198         return mPackageName;
199     }
200 
201     @Override
getBadgedImage()202     public Bitmap getBadgedImage() {
203         return mBadgedImage;
204     }
205 
getBadgedAppIcon()206     public Drawable getBadgedAppIcon() {
207         return mBadgedAppIcon;
208     }
209 
210     @Override
getDotColor()211     public int getDotColor() {
212         return mDotColor;
213     }
214 
215     @Override
getDotPath()216     public Path getDotPath() {
217         return mDotPath;
218     }
219 
220     @Nullable
getAppName()221     public String getAppName() {
222         return mAppName;
223     }
224 
225     @Nullable
getShortcutInfo()226     public ShortcutInfo getShortcutInfo() {
227         return mShortcutInfo;
228     }
229 
230     @Nullable
231     @Override
getIconView()232     public BadgedImageView getIconView() {
233         return mIconView;
234     }
235 
236     @Override
237     @Nullable
getExpandedView()238     public BubbleExpandedView getExpandedView() {
239         return mExpandedView;
240     }
241 
242     @Nullable
getTitle()243     public String getTitle() {
244         return mTitle;
245     }
246 
getMetadataShortcutId()247     String getMetadataShortcutId() {
248         return mMetadataShortcutId;
249     }
250 
hasMetadataShortcutId()251     boolean hasMetadataShortcutId() {
252         return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty());
253     }
254 
255     /**
256      * Call when the views should be removed, ensure this is called to clean up ActivityView
257      * content.
258      */
cleanupViews()259     void cleanupViews() {
260         if (mExpandedView != null) {
261             mExpandedView.cleanUpExpandedState();
262             mExpandedView = null;
263         }
264         mIconView = null;
265         if (mIntent != null) {
266             mIntent.unregisterCancelListener(mIntentCancelListener);
267         }
268         mIntentActive = false;
269     }
270 
setPendingIntentCanceled()271     void setPendingIntentCanceled() {
272         mPendingIntentCanceled = true;
273     }
274 
getPendingIntentCanceled()275     boolean getPendingIntentCanceled() {
276         return mPendingIntentCanceled;
277     }
278 
279     /**
280      * Sets whether to perform inflation on the same thread as the caller. This method should only
281      * be used in tests, not in production.
282      */
283     @VisibleForTesting
setInflateSynchronously(boolean inflateSynchronously)284     void setInflateSynchronously(boolean inflateSynchronously) {
285         mInflateSynchronously = inflateSynchronously;
286     }
287 
288     /**
289      * Starts a task to inflate & load any necessary information to display a bubble.
290      *
291      * @param callback the callback to notify one the bubble is ready to be displayed.
292      * @param context the context for the bubble.
293      * @param stackView the stackView the bubble is eventually added to.
294      * @param iconFactory the iconfactory use to create badged images for the bubble.
295      */
inflate(BubbleViewInfoTask.Callback callback, Context context, BubbleStackView stackView, BubbleIconFactory iconFactory, boolean skipInflation)296     void inflate(BubbleViewInfoTask.Callback callback,
297             Context context,
298             BubbleStackView stackView,
299             BubbleIconFactory iconFactory,
300             boolean skipInflation) {
301         if (isBubbleLoading()) {
302             mInflationTask.cancel(true /* mayInterruptIfRunning */);
303         }
304         mInflationTask = new BubbleViewInfoTask(this,
305                 context,
306                 stackView,
307                 iconFactory,
308                 skipInflation,
309                 callback);
310         if (mInflateSynchronously) {
311             mInflationTask.onPostExecute(mInflationTask.doInBackground());
312         } else {
313             mInflationTask.execute();
314         }
315     }
316 
isBubbleLoading()317     private boolean isBubbleLoading() {
318         return mInflationTask != null && mInflationTask.getStatus() != FINISHED;
319     }
320 
isInflated()321     boolean isInflated() {
322         return mIconView != null && mExpandedView != null;
323     }
324 
stopInflation()325     void stopInflation() {
326         if (mInflationTask == null) {
327             return;
328         }
329         mInflationTask.cancel(true /* mayInterruptIfRunning */);
330         cleanupViews();
331     }
332 
setViewInfo(BubbleViewInfoTask.BubbleViewInfo info)333     void setViewInfo(BubbleViewInfoTask.BubbleViewInfo info) {
334         if (!isInflated()) {
335             mIconView = info.imageView;
336             mExpandedView = info.expandedView;
337         }
338 
339         mShortcutInfo = info.shortcutInfo;
340         mAppName = info.appName;
341         mFlyoutMessage = info.flyoutMessage;
342 
343         mBadgedAppIcon = info.badgedAppIcon;
344         mBadgedImage = info.badgedBubbleImage;
345         mDotColor = info.dotColor;
346         mDotPath = info.dotPath;
347 
348         if (mExpandedView != null) {
349             mExpandedView.update(this /* bubble */);
350         }
351         if (mIconView != null) {
352             mIconView.setRenderedBubble(this /* bubble */);
353         }
354     }
355 
356     /**
357      * Set visibility of bubble in the expanded state.
358      *
359      * @param visibility {@code true} if the expanded bubble should be visible on the screen.
360      *
361      * Note that this contents visibility doesn't affect visibility at {@link android.view.View},
362      * and setting {@code false} actually means rendering the expanded view in transparent.
363      */
364     @Override
setContentVisibility(boolean visibility)365     public void setContentVisibility(boolean visibility) {
366         if (mExpandedView != null) {
367             mExpandedView.setContentVisibility(visibility);
368         }
369     }
370 
371     /**
372      * Sets the entry associated with this bubble.
373      */
setEntry(@onNull final NotificationEntry entry)374     void setEntry(@NonNull final NotificationEntry entry) {
375         Objects.requireNonNull(entry);
376         Objects.requireNonNull(entry.getSbn());
377         mLastUpdated = entry.getSbn().getPostTime();
378         mIsBubble = entry.getSbn().getNotification().isBubbleNotification();
379         mPackageName = entry.getSbn().getPackageName();
380         mUser = entry.getSbn().getUser();
381         mTitle = getTitle(entry);
382         mIsClearable = entry.isClearable();
383         mShouldSuppressNotificationDot = entry.shouldSuppressNotificationDot();
384         mShouldSuppressNotificationList = entry.shouldSuppressNotificationList();
385         mShouldSuppressPeek = entry.shouldSuppressPeek();
386         mChannelId = entry.getSbn().getNotification().getChannelId();
387         mNotificationId = entry.getSbn().getId();
388         mAppUid = entry.getSbn().getUid();
389         mInstanceId = entry.getSbn().getInstanceId();
390         mFlyoutMessage = BubbleViewInfoTask.extractFlyoutMessage(entry);
391         mShortcutInfo = (entry.getRanking() != null ? entry.getRanking().getShortcutInfo() : null);
392         mMetadataShortcutId = (entry.getBubbleMetadata() != null
393                 ? entry.getBubbleMetadata().getShortcutId() : null);
394         if (entry.getRanking() != null) {
395             mIsVisuallyInterruptive = entry.getRanking().visuallyInterruptive();
396         }
397         if (entry.getBubbleMetadata() != null) {
398             mFlags = entry.getBubbleMetadata().getFlags();
399             mDesiredHeight = entry.getBubbleMetadata().getDesiredHeight();
400             mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId();
401             mIcon = entry.getBubbleMetadata().getIcon();
402 
403             if (!mIntentActive || mIntent == null) {
404                 if (mIntent != null) {
405                     mIntent.unregisterCancelListener(mIntentCancelListener);
406                 }
407                 mIntent = entry.getBubbleMetadata().getIntent();
408                 if (mIntent != null) {
409                     mIntent.registerCancelListener(mIntentCancelListener);
410                 }
411             } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) {
412                 // Was an intent bubble now it's a shortcut bubble... still unregister the listener
413                 mIntent.unregisterCancelListener(mIntentCancelListener);
414                 mIntent = null;
415             }
416             mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent();
417         }
418         mIsImportantConversation =
419                 entry.getChannel() != null && entry.getChannel().isImportantConversation();
420     }
421 
422     @Nullable
getIcon()423     Icon getIcon() {
424         return mIcon;
425     }
426 
isVisuallyInterruptive()427     boolean isVisuallyInterruptive() {
428         return mIsVisuallyInterruptive;
429     }
430 
431     /**
432      * @return the last time this bubble was updated or accessed, whichever is most recent.
433      */
getLastActivity()434     long getLastActivity() {
435         return Math.max(mLastUpdated, mLastAccessed);
436     }
437 
438     /**
439      * Sets if the intent used for this bubble is currently active (i.e. populating an
440      * expanded view, expanded or not).
441      */
setIntentActive()442     void setIntentActive() {
443         mIntentActive = true;
444     }
445 
isIntentActive()446     boolean isIntentActive() {
447         return mIntentActive;
448     }
449 
450     /**
451      * @return the display id of the virtual display on which bubble contents is drawn.
452      */
453     @Override
getDisplayId()454     public int getDisplayId() {
455         return mExpandedView != null ? mExpandedView.getVirtualDisplayId() : INVALID_DISPLAY;
456     }
457 
getInstanceId()458     public InstanceId getInstanceId() {
459         return mInstanceId;
460     }
461 
462     @Nullable
getChannelId()463     public String getChannelId() {
464         return mChannelId;
465     }
466 
getNotificationId()467     public int getNotificationId() {
468         return mNotificationId;
469     }
470 
471     /**
472      * Should be invoked whenever a Bubble is accessed (selected while expanded).
473      */
markAsAccessedAt(long lastAccessedMillis)474     void markAsAccessedAt(long lastAccessedMillis) {
475         mLastAccessed = lastAccessedMillis;
476         setSuppressNotification(true);
477         setShowDot(false /* show */);
478     }
479 
480     /**
481      * Should be invoked whenever a Bubble is promoted from overflow.
482      */
markUpdatedAt(long lastAccessedMillis)483     void markUpdatedAt(long lastAccessedMillis) {
484         mLastUpdated = lastAccessedMillis;
485     }
486 
487     /**
488      * Whether this notification should be shown in the shade.
489      */
showInShade()490     boolean showInShade() {
491         return !shouldSuppressNotification() || !mIsClearable;
492     }
493 
494     /**
495      * Whether this notification conversation is important.
496      */
isImportantConversation()497     boolean isImportantConversation() {
498         return mIsImportantConversation;
499     }
500 
501     /**
502      * Sets whether this notification should be suppressed in the shade.
503      */
setSuppressNotification(boolean suppressNotification)504     void setSuppressNotification(boolean suppressNotification) {
505         boolean prevShowInShade = showInShade();
506         if (suppressNotification) {
507             mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
508         } else {
509             mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
510         }
511 
512         if (showInShade() != prevShowInShade && mSuppressionListener != null) {
513             mSuppressionListener.onBubbleNotificationSuppressionChange(this);
514         }
515     }
516 
517     /**
518      * Sets whether the bubble for this notification should show a dot indicating updated content.
519      */
setShowDot(boolean showDot)520     void setShowDot(boolean showDot) {
521         mShowBubbleUpdateDot = showDot;
522 
523         if (mIconView != null) {
524             mIconView.updateDotVisibility(true /* animate */);
525         }
526     }
527 
528     /**
529      * Whether the bubble for this notification should show a dot indicating updated content.
530      */
531     @Override
showDot()532     public boolean showDot() {
533         return mShowBubbleUpdateDot
534                 && !mShouldSuppressNotificationDot
535                 && !shouldSuppressNotification();
536     }
537 
538     /**
539      * Whether the flyout for the bubble should be shown.
540      */
showFlyout()541     boolean showFlyout() {
542         return !mSuppressFlyout && !mShouldSuppressPeek
543                 && !shouldSuppressNotification()
544                 && !mShouldSuppressNotificationList;
545     }
546 
547     /**
548      * Set whether the flyout text for the bubble should be shown when an update is received.
549      *
550      * @param suppressFlyout whether the flyout text is shown
551      */
setSuppressFlyout(boolean suppressFlyout)552     void setSuppressFlyout(boolean suppressFlyout) {
553         mSuppressFlyout = suppressFlyout;
554     }
555 
getFlyoutMessage()556     FlyoutMessage getFlyoutMessage() {
557         return mFlyoutMessage;
558     }
559 
getRawDesiredHeight()560     int getRawDesiredHeight() {
561         return mDesiredHeight;
562     }
563 
getRawDesiredHeightResId()564     int getRawDesiredHeightResId() {
565         return mDesiredHeightResId;
566     }
567 
getDesiredHeight(Context context)568     float getDesiredHeight(Context context) {
569         boolean useRes = mDesiredHeightResId != 0;
570         if (useRes) {
571             return getDimenForPackageUser(context, mDesiredHeightResId, mPackageName,
572                     mUser.getIdentifier());
573         } else {
574             return mDesiredHeight * context.getResources().getDisplayMetrics().density;
575         }
576     }
577 
getDesiredHeightString()578     String getDesiredHeightString() {
579         boolean useRes = mDesiredHeightResId != 0;
580         if (useRes) {
581             return String.valueOf(mDesiredHeightResId);
582         } else {
583             return String.valueOf(mDesiredHeight);
584         }
585     }
586 
587     @Nullable
getBubbleIntent()588     PendingIntent getBubbleIntent() {
589         return mIntent;
590     }
591 
592     @Nullable
getDeleteIntent()593     PendingIntent getDeleteIntent() {
594         return mDeleteIntent;
595     }
596 
getSettingsIntent(final Context context)597     Intent getSettingsIntent(final Context context) {
598         final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS);
599         intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
600         final int uid = getUid(context);
601         if (uid != -1) {
602             intent.putExtra(Settings.EXTRA_APP_UID, uid);
603         }
604         intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
605         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
606         intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
607         return intent;
608     }
609 
getAppUid()610     public int getAppUid() {
611         return mAppUid;
612     }
613 
getUid(final Context context)614     private int getUid(final Context context) {
615         if (mAppUid != -1) return mAppUid;
616         final PackageManager pm = context.getPackageManager();
617         if (pm == null) return -1;
618         try {
619             final ApplicationInfo info = pm.getApplicationInfo(mShortcutInfo.getPackage(), 0);
620             return info.uid;
621         } catch (PackageManager.NameNotFoundException e) {
622             Log.e(TAG, "cannot find uid", e);
623         }
624         return -1;
625     }
626 
getDimenForPackageUser(Context context, int resId, String pkg, int userId)627     private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) {
628         PackageManager pm = context.getPackageManager();
629         Resources r;
630         if (pkg != null) {
631             try {
632                 if (userId == UserHandle.USER_ALL) {
633                     userId = UserHandle.USER_SYSTEM;
634                 }
635                 r = pm.getResourcesForApplicationAsUser(pkg, userId);
636                 return r.getDimensionPixelSize(resId);
637             } catch (PackageManager.NameNotFoundException ex) {
638                 // Uninstalled, don't care
639             } catch (Resources.NotFoundException e) {
640                 // Invalid res id, return 0 and user our default
641                 Log.e(TAG, "Couldn't find desired height res id", e);
642             }
643         }
644         return 0;
645     }
646 
shouldSuppressNotification()647     private boolean shouldSuppressNotification() {
648         return isEnabled(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION);
649     }
650 
shouldAutoExpand()651     public boolean shouldAutoExpand() {
652         return isEnabled(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
653     }
654 
setShouldAutoExpand(boolean shouldAutoExpand)655     void setShouldAutoExpand(boolean shouldAutoExpand) {
656         if (shouldAutoExpand) {
657             enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
658         } else {
659             disable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
660         }
661     }
662 
setIsBubble(final boolean isBubble)663     public void setIsBubble(final boolean isBubble) {
664         mIsBubble = isBubble;
665     }
666 
isBubble()667     public boolean isBubble() {
668         return mIsBubble;
669     }
670 
enable(int option)671     public void enable(int option) {
672         mFlags |= option;
673     }
674 
disable(int option)675     public void disable(int option) {
676         mFlags &= ~option;
677     }
678 
isEnabled(int option)679     public boolean isEnabled(int option) {
680         return (mFlags & option) != 0;
681     }
682 
683     @Override
toString()684     public String toString() {
685         return "Bubble{" + mKey + '}';
686     }
687 
688     /**
689      * Description of current bubble state.
690      */
dump( @onNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args)691     public void dump(
692             @NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) {
693         pw.print("key: "); pw.println(mKey);
694         pw.print("  showInShade:   "); pw.println(showInShade());
695         pw.print("  showDot:       "); pw.println(showDot());
696         pw.print("  showFlyout:    "); pw.println(showFlyout());
697         pw.print("  desiredHeight: "); pw.println(getDesiredHeightString());
698         pw.print("  suppressNotif: "); pw.println(shouldSuppressNotification());
699         pw.print("  autoExpand:    "); pw.println(shouldAutoExpand());
700     }
701 
702     @Override
equals(Object o)703     public boolean equals(Object o) {
704         if (this == o) return true;
705         if (!(o instanceof Bubble)) return false;
706         Bubble bubble = (Bubble) o;
707         return Objects.equals(mKey, bubble.mKey);
708     }
709 
710     @Override
hashCode()711     public int hashCode() {
712         return Objects.hash(mKey);
713     }
714 
715     @Override
logUIEvent(int bubbleCount, int action, float normalX, float normalY, int index)716     public void logUIEvent(int bubbleCount, int action, float normalX, float normalY, int index) {
717         SysUiStatsLog.write(SysUiStatsLog.BUBBLE_UI_CHANGED,
718                 mPackageName,
719                 mChannelId,
720                 mNotificationId,
721                 index,
722                 bubbleCount,
723                 action,
724                 normalX,
725                 normalY,
726                 showInShade(),
727                 false /* isOngoing (unused) */,
728                 false /* isAppForeground (unused) */);
729     }
730 
731     @Nullable
getTitle(@onNull final NotificationEntry e)732     private static String getTitle(@NonNull final NotificationEntry e) {
733         final CharSequence titleCharSeq = e.getSbn().getNotification().extras.getCharSequence(
734                 Notification.EXTRA_TITLE);
735         return titleCharSeq == null ? null : titleCharSeq.toString();
736     }
737 }
738