1 /*
2  * Copyright (C) 2020 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.wm.shell.bubbles;
17 
18 import static android.app.ActivityTaskManager.INVALID_TASK_ID;
19 import static android.os.AsyncTask.Status.FINISHED;
20 
21 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
22 
23 import android.annotation.DimenRes;
24 import android.annotation.Hide;
25 import android.annotation.NonNull;
26 import android.annotation.Nullable;
27 import android.app.Notification;
28 import android.app.PendingIntent;
29 import android.app.Person;
30 import android.content.Context;
31 import android.content.Intent;
32 import android.content.LocusId;
33 import android.content.pm.ApplicationInfo;
34 import android.content.pm.PackageManager;
35 import android.content.pm.ShortcutInfo;
36 import android.content.res.Resources;
37 import android.graphics.Bitmap;
38 import android.graphics.Path;
39 import android.graphics.drawable.Drawable;
40 import android.graphics.drawable.Icon;
41 import android.os.Parcelable;
42 import android.os.UserHandle;
43 import android.provider.Settings;
44 import android.service.notification.StatusBarNotification;
45 import android.text.TextUtils;
46 import android.util.Log;
47 
48 import com.android.internal.annotations.VisibleForTesting;
49 import com.android.internal.logging.InstanceId;
50 import com.android.launcher3.icons.BubbleIconFactory;
51 import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView;
52 import com.android.wm.shell.bubbles.bar.BubbleBarLayerView;
53 import com.android.wm.shell.common.bubbles.BubbleInfo;
54 
55 import java.io.PrintWriter;
56 import java.util.List;
57 import java.util.Objects;
58 import java.util.concurrent.Executor;
59 
60 /**
61  * Encapsulates the data and UI elements of a bubble.
62  */
63 public class Bubble implements BubbleViewProvider {
64     private static final String TAG = "Bubble";
65 
66     /** A string suffix used in app bubbles' {@link #mKey}. */
67     private static final String KEY_APP_BUBBLE = "key_app_bubble";
68 
69     /** Whether the bubble is an app bubble. */
70     private final boolean mIsAppBubble;
71 
72     private final String mKey;
73     @Nullable
74     private final String mGroupKey;
75     @Nullable
76     private final LocusId mLocusId;
77 
78     private final Executor mMainExecutor;
79 
80     private long mLastUpdated;
81     private long mLastAccessed;
82 
83     @Nullable
84     private Bubbles.BubbleMetadataFlagListener mBubbleMetadataFlagListener;
85 
86     /** Whether the bubble should show a dot for the notification indicating updated content. */
87     private boolean mShowBubbleUpdateDot = true;
88 
89     /** Whether flyout text should be suppressed, regardless of any other flags or state. */
90     private boolean mSuppressFlyout;
91 
92     // Items that are typically loaded later
93     private String mAppName;
94     private ShortcutInfo mShortcutInfo;
95     private String mMetadataShortcutId;
96 
97     /**
98      * If {@link BubbleController#isShowingAsBubbleBar()} is true, the only view that will be
99      * populated will be {@link #mBubbleBarExpandedView}. If it is false, {@link #mIconView}
100      * and {@link #mExpandedView} will be populated.
101      */
102     @Nullable
103     private BadgedImageView mIconView;
104     @Nullable
105     private BubbleExpandedView mExpandedView;
106     @Nullable
107     private BubbleBarExpandedView mBubbleBarExpandedView;
108 
109     private BubbleViewInfoTask mInflationTask;
110     private boolean mInflateSynchronously;
111     private boolean mPendingIntentCanceled;
112     private boolean mIsImportantConversation;
113 
114     /**
115      * Presentational info about the flyout.
116      */
117     public static class FlyoutMessage {
118         @Nullable public Icon senderIcon;
119         @Nullable public Drawable senderAvatar;
120         @Nullable public CharSequence senderName;
121         @Nullable public CharSequence message;
122         @Nullable public boolean isGroupChat;
123     }
124 
125     private FlyoutMessage mFlyoutMessage;
126     // The developer provided image for the bubble
127     private Bitmap mBubbleBitmap;
128     // The app badge for the bubble
129     private Bitmap mBadgeBitmap;
130     // App badge without any markings for important conversations
131     private Bitmap mRawBadgeBitmap;
132     private int mDotColor;
133     private Path mDotPath;
134     private int mFlags;
135 
136     @NonNull
137     private UserHandle mUser;
138     @NonNull
139     private String mPackageName;
140     @Nullable
141     private String mTitle;
142     @Nullable
143     private Icon mIcon;
144     private boolean mIsBubble;
145     private boolean mIsTextChanged;
146     private boolean mIsDismissable;
147     private boolean mShouldSuppressNotificationDot;
148     private boolean mShouldSuppressNotificationList;
149     private boolean mShouldSuppressPeek;
150     private int mDesiredHeight;
151     @DimenRes
152     private int mDesiredHeightResId;
153     private int mTaskId;
154 
155     /** for logging **/
156     @Nullable
157     private InstanceId mInstanceId;
158     @Nullable
159     private String mChannelId;
160     private int mNotificationId;
161     private int mAppUid = -1;
162 
163     /**
164      * A bubble is created and can be updated. This intent is updated until the user first
165      * expands the bubble. Once the user has expanded the contents, we ignore the intent updates
166      * to prevent restarting the intent & possibly altering UI state in the activity in front of
167      * the user.
168      *
169      * Once the bubble is overflowed, the activity is finished and updates to the
170      * notification are respected. Typically an update to an overflowed bubble would result in
171      * that bubble being added back to the stack anyways.
172      */
173     @Nullable
174     private PendingIntent mIntent;
175     private boolean mIntentActive;
176     @Nullable
177     private PendingIntent.CancelListener mIntentCancelListener;
178 
179     /**
180      * Sent when the bubble & notification are no longer visible to the user (i.e. no
181      * notification in the shade, no bubble in the stack or overflow).
182      */
183     @Nullable
184     private PendingIntent mDeleteIntent;
185 
186     /**
187      * Used only for a special bubble in the stack that has {@link #mIsAppBubble} set to true.
188      * There can only be one of these bubbles in the stack and this intent will be populated for
189      * that bubble.
190      */
191     @Nullable
192     private Intent mAppIntent;
193 
194     /**
195      * Create a bubble with limited information based on given {@link ShortcutInfo}.
196      * Note: Currently this is only being used when the bubble is persisted to disk.
197      */
198     @VisibleForTesting(visibility = PRIVATE)
Bubble(@onNull final String key, @NonNull final ShortcutInfo shortcutInfo, final int desiredHeight, final int desiredHeightResId, @Nullable final String title, int taskId, @Nullable final String locus, boolean isDismissable, Executor mainExecutor, final Bubbles.BubbleMetadataFlagListener listener)199     public Bubble(@NonNull final String key, @NonNull final ShortcutInfo shortcutInfo,
200             final int desiredHeight, final int desiredHeightResId, @Nullable final String title,
201             int taskId, @Nullable final String locus, boolean isDismissable, Executor mainExecutor,
202             final Bubbles.BubbleMetadataFlagListener listener) {
203         Objects.requireNonNull(key);
204         Objects.requireNonNull(shortcutInfo);
205         mMetadataShortcutId = shortcutInfo.getId();
206         mShortcutInfo = shortcutInfo;
207         mKey = key;
208         mGroupKey = null;
209         mLocusId = locus != null ? new LocusId(locus) : null;
210         mIsDismissable = isDismissable;
211         mFlags = 0;
212         mUser = shortcutInfo.getUserHandle();
213         mPackageName = shortcutInfo.getPackage();
214         mIcon = shortcutInfo.getIcon();
215         mDesiredHeight = desiredHeight;
216         mDesiredHeightResId = desiredHeightResId;
217         mTitle = title;
218         mShowBubbleUpdateDot = false;
219         mMainExecutor = mainExecutor;
220         mTaskId = taskId;
221         mBubbleMetadataFlagListener = listener;
222         mIsAppBubble = false;
223     }
224 
Bubble( Intent intent, UserHandle user, @Nullable Icon icon, boolean isAppBubble, String key, Executor mainExecutor)225     private Bubble(
226             Intent intent,
227             UserHandle user,
228             @Nullable Icon icon,
229             boolean isAppBubble,
230             String key,
231             Executor mainExecutor) {
232         mGroupKey = null;
233         mLocusId = null;
234         mFlags = 0;
235         mUser = user;
236         mIcon = icon;
237         mIsAppBubble = isAppBubble;
238         mKey = key;
239         mShowBubbleUpdateDot = false;
240         mMainExecutor = mainExecutor;
241         mTaskId = INVALID_TASK_ID;
242         mAppIntent = intent;
243         mDesiredHeight = Integer.MAX_VALUE;
244         mPackageName = intent.getPackage();
245 
246     }
247 
248     /** Creates an app bubble. */
createAppBubble( Intent intent, UserHandle user, @Nullable Icon icon, Executor mainExecutor)249     public static Bubble createAppBubble(
250             Intent intent,
251             UserHandle user,
252             @Nullable Icon icon,
253             Executor mainExecutor) {
254         return new Bubble(intent,
255                 user,
256                 icon,
257                 /* isAppBubble= */ true,
258                 /* key= */ getAppBubbleKeyForApp(intent.getPackage(), user),
259                 mainExecutor);
260     }
261 
262     /**
263      * Returns the key for an app bubble from an app with package name, {@code packageName} on an
264      * Android user, {@code user}.
265      */
getAppBubbleKeyForApp(String packageName, UserHandle user)266     public static String getAppBubbleKeyForApp(String packageName, UserHandle user) {
267         Objects.requireNonNull(packageName);
268         Objects.requireNonNull(user);
269         return KEY_APP_BUBBLE + ":" + user.getIdentifier()  + ":" + packageName;
270     }
271 
272     @VisibleForTesting(visibility = PRIVATE)
Bubble(@onNull final BubbleEntry entry, final Bubbles.BubbleMetadataFlagListener listener, final Bubbles.PendingIntentCanceledListener intentCancelListener, Executor mainExecutor)273     public Bubble(@NonNull final BubbleEntry entry,
274             final Bubbles.BubbleMetadataFlagListener listener,
275             final Bubbles.PendingIntentCanceledListener intentCancelListener,
276             Executor mainExecutor) {
277         mIsAppBubble = false;
278         mKey = entry.getKey();
279         mGroupKey = entry.getGroupKey();
280         mLocusId = entry.getLocusId();
281         mBubbleMetadataFlagListener = listener;
282         mIntentCancelListener = intent -> {
283             if (mIntent != null) {
284                 mIntent.unregisterCancelListener(mIntentCancelListener);
285             }
286             mainExecutor.execute(() -> {
287                 intentCancelListener.onPendingIntentCanceled(this);
288             });
289         };
290         mMainExecutor = mainExecutor;
291         mTaskId = INVALID_TASK_ID;
292         setEntry(entry);
293     }
294 
295     /** Converts this bubble into a {@link BubbleInfo} object to be shared with external callers. */
asBubbleBarBubble()296     public BubbleInfo asBubbleBarBubble() {
297         return new BubbleInfo(getKey(),
298                 getFlags(),
299                 getShortcutId(),
300                 getIcon(),
301                 getUser().getIdentifier(),
302                 getPackageName(),
303                 getTitle(),
304                 isImportantConversation());
305     }
306 
307     @Override
getKey()308     public String getKey() {
309         return mKey;
310     }
311 
312     @Hide
isDismissable()313     public boolean isDismissable() {
314         return mIsDismissable;
315     }
316 
317     /**
318      * @see StatusBarNotification#getGroupKey()
319      * @return the group key for this bubble, if one exists.
320      */
getGroupKey()321     public String getGroupKey() {
322         return mGroupKey;
323     }
324 
getLocusId()325     public LocusId getLocusId() {
326         return mLocusId;
327     }
328 
getUser()329     public UserHandle getUser() {
330         return mUser;
331     }
332 
333     @NonNull
getPackageName()334     public String getPackageName() {
335         return mPackageName;
336     }
337 
338     @Override
getBubbleIcon()339     public Bitmap getBubbleIcon() {
340         return mBubbleBitmap;
341     }
342 
343     @Override
getAppBadge()344     public Bitmap getAppBadge() {
345         return mBadgeBitmap;
346     }
347 
348     @Override
getRawAppBadge()349     public Bitmap getRawAppBadge() {
350         return mRawBadgeBitmap;
351     }
352 
353     @Override
getDotColor()354     public int getDotColor() {
355         return mDotColor;
356     }
357 
358     @Override
getDotPath()359     public Path getDotPath() {
360         return mDotPath;
361     }
362 
363     @Nullable
getAppName()364     public String getAppName() {
365         return mAppName;
366     }
367 
368     @Nullable
getShortcutInfo()369     public ShortcutInfo getShortcutInfo() {
370         return mShortcutInfo;
371     }
372 
373     @Nullable
374     @Override
getIconView()375     public BadgedImageView getIconView() {
376         return mIconView;
377     }
378 
379     @Nullable
380     @Override
getExpandedView()381     public BubbleExpandedView getExpandedView() {
382         return mExpandedView;
383     }
384 
385     @Nullable
386     @Override
getBubbleBarExpandedView()387     public BubbleBarExpandedView getBubbleBarExpandedView() {
388         return mBubbleBarExpandedView;
389     }
390 
391     @Nullable
getTitle()392     public String getTitle() {
393         return mTitle;
394     }
395 
396     /**
397      * @return the ShortcutInfo id if it exists, or the metadata shortcut id otherwise.
398      */
getShortcutId()399     String getShortcutId() {
400         return getShortcutInfo() != null
401                 ? getShortcutInfo().getId()
402                 : getMetadataShortcutId();
403     }
404 
getMetadataShortcutId()405     String getMetadataShortcutId() {
406         return mMetadataShortcutId;
407     }
408 
hasMetadataShortcutId()409     boolean hasMetadataShortcutId() {
410         return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty());
411     }
412 
413     /**
414      * Call this to clean up the task for the bubble. Ensure this is always called when done with
415      * the bubble.
416      */
cleanupExpandedView()417     void cleanupExpandedView() {
418         if (mExpandedView != null) {
419             mExpandedView.cleanUpExpandedState();
420             mExpandedView = null;
421         }
422         if (mBubbleBarExpandedView != null) {
423             mBubbleBarExpandedView.cleanUpExpandedState();
424             mBubbleBarExpandedView = null;
425         }
426         if (mIntent != null) {
427             mIntent.unregisterCancelListener(mIntentCancelListener);
428         }
429         mIntentActive = false;
430     }
431 
432     /**
433      * Call when all the views should be removed/cleaned up.
434      */
cleanupViews()435     void cleanupViews() {
436         cleanupExpandedView();
437         mIconView = null;
438     }
439 
setPendingIntentCanceled()440     void setPendingIntentCanceled() {
441         mPendingIntentCanceled = true;
442     }
443 
getPendingIntentCanceled()444     boolean getPendingIntentCanceled() {
445         return mPendingIntentCanceled;
446     }
447 
448     /**
449      * Sets whether to perform inflation on the same thread as the caller. This method should only
450      * be used in tests, not in production.
451      */
452     @VisibleForTesting
setInflateSynchronously(boolean inflateSynchronously)453     void setInflateSynchronously(boolean inflateSynchronously) {
454         mInflateSynchronously = inflateSynchronously;
455     }
456 
457     /**
458      * Sets whether this bubble is considered text changed. This method is purely for
459      * testing.
460      */
461     @VisibleForTesting
setTextChangedForTest(boolean textChanged)462     void setTextChangedForTest(boolean textChanged) {
463         mIsTextChanged = textChanged;
464     }
465 
466     /**
467      * Starts a task to inflate & load any necessary information to display a bubble.
468      *
469      * @param callback the callback to notify one the bubble is ready to be displayed.
470      * @param context the context for the bubble.
471      * @param controller the bubble controller.
472      * @param stackView the view the bubble is added to, iff showing as floating.
473      * @param layerView the layer the bubble is added to, iff showing in the bubble bar.
474      * @param iconFactory the icon factory use to create images for the bubble.
475      */
inflate(BubbleViewInfoTask.Callback callback, Context context, BubbleController controller, @Nullable BubbleStackView stackView, @Nullable BubbleBarLayerView layerView, BubbleIconFactory iconFactory, boolean skipInflation)476     void inflate(BubbleViewInfoTask.Callback callback,
477             Context context,
478             BubbleController controller,
479             @Nullable BubbleStackView stackView,
480             @Nullable BubbleBarLayerView layerView,
481             BubbleIconFactory iconFactory,
482             boolean skipInflation) {
483         if (isBubbleLoading()) {
484             mInflationTask.cancel(true /* mayInterruptIfRunning */);
485         }
486         mInflationTask = new BubbleViewInfoTask(this,
487                 context,
488                 controller,
489                 stackView,
490                 layerView,
491                 iconFactory,
492                 skipInflation,
493                 callback,
494                 mMainExecutor);
495         if (mInflateSynchronously) {
496             mInflationTask.onPostExecute(mInflationTask.doInBackground());
497         } else {
498             mInflationTask.execute();
499         }
500     }
501 
isBubbleLoading()502     private boolean isBubbleLoading() {
503         return mInflationTask != null && mInflationTask.getStatus() != FINISHED;
504     }
505 
isInflated()506     boolean isInflated() {
507         return (mIconView != null && mExpandedView != null) || mBubbleBarExpandedView != null;
508     }
509 
stopInflation()510     void stopInflation() {
511         if (mInflationTask == null) {
512             return;
513         }
514         mInflationTask.cancel(true /* mayInterruptIfRunning */);
515     }
516 
setViewInfo(BubbleViewInfoTask.BubbleViewInfo info)517     void setViewInfo(BubbleViewInfoTask.BubbleViewInfo info) {
518         if (!isInflated()) {
519             mIconView = info.imageView;
520             mExpandedView = info.expandedView;
521             mBubbleBarExpandedView = info.bubbleBarExpandedView;
522         }
523 
524         mShortcutInfo = info.shortcutInfo;
525         mAppName = info.appName;
526         if (mTitle == null) {
527             mTitle = mAppName;
528         }
529         mFlyoutMessage = info.flyoutMessage;
530 
531         mBadgeBitmap = info.badgeBitmap;
532         mRawBadgeBitmap = info.rawBadgeBitmap;
533         mBubbleBitmap = info.bubbleBitmap;
534 
535         mDotColor = info.dotColor;
536         mDotPath = info.dotPath;
537 
538         if (mExpandedView != null) {
539             mExpandedView.update(this /* bubble */);
540         }
541         if (mBubbleBarExpandedView != null) {
542             mBubbleBarExpandedView.update(this /* bubble */);
543         }
544         if (mIconView != null) {
545             mIconView.setRenderedBubble(this /* bubble */);
546         }
547     }
548 
549     /**
550      * Set visibility of bubble in the expanded state.
551      *
552      * <p>Note that this contents visibility doesn't affect visibility at {@link android.view.View},
553      * and setting {@code false} actually means rendering the expanded view in transparent.
554      *
555      * @param visibility {@code true} if the expanded bubble should be visible on the screen.
556      */
557     @Override
setTaskViewVisibility(boolean visibility)558     public void setTaskViewVisibility(boolean visibility) {
559         if (mExpandedView != null) {
560             mExpandedView.setContentVisibility(visibility);
561         }
562     }
563 
564     /**
565      * Sets the entry associated with this bubble.
566      */
setEntry(@onNull final BubbleEntry entry)567     void setEntry(@NonNull final BubbleEntry entry) {
568         Objects.requireNonNull(entry);
569         boolean showingDotPreviously = showDot();
570         mLastUpdated = entry.getStatusBarNotification().getPostTime();
571         mIsBubble = entry.getStatusBarNotification().getNotification().isBubbleNotification();
572         mPackageName = entry.getStatusBarNotification().getPackageName();
573         mUser = entry.getStatusBarNotification().getUser();
574         mTitle = getTitle(entry);
575         mChannelId = entry.getStatusBarNotification().getNotification().getChannelId();
576         mNotificationId = entry.getStatusBarNotification().getId();
577         mAppUid = entry.getStatusBarNotification().getUid();
578         mInstanceId = entry.getStatusBarNotification().getInstanceId();
579         mFlyoutMessage = extractFlyoutMessage(entry);
580         if (entry.getRanking() != null) {
581             mShortcutInfo = entry.getRanking().getConversationShortcutInfo();
582             mIsTextChanged = entry.getRanking().isTextChanged();
583             if (entry.getRanking().getChannel() != null) {
584                 mIsImportantConversation =
585                         entry.getRanking().getChannel().isImportantConversation();
586             }
587         }
588         if (entry.getBubbleMetadata() != null) {
589             mMetadataShortcutId = entry.getBubbleMetadata().getShortcutId();
590             mFlags = entry.getBubbleMetadata().getFlags();
591             mDesiredHeight = entry.getBubbleMetadata().getDesiredHeight();
592             mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId();
593             mIcon = entry.getBubbleMetadata().getIcon();
594 
595             if (!mIntentActive || mIntent == null) {
596                 if (mIntent != null) {
597                     mIntent.unregisterCancelListener(mIntentCancelListener);
598                 }
599                 mIntent = entry.getBubbleMetadata().getIntent();
600                 if (mIntent != null) {
601                     mIntent.registerCancelListener(mIntentCancelListener);
602                 }
603             } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) {
604                 // Was an intent bubble now it's a shortcut bubble... still unregister the listener
605                 mIntent.unregisterCancelListener(mIntentCancelListener);
606                 mIntentActive = false;
607                 mIntent = null;
608             }
609             mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent();
610         }
611 
612         mIsDismissable = entry.isDismissable();
613         mShouldSuppressNotificationDot = entry.shouldSuppressNotificationDot();
614         mShouldSuppressNotificationList = entry.shouldSuppressNotificationList();
615         mShouldSuppressPeek = entry.shouldSuppressPeek();
616         if (showingDotPreviously != showDot()) {
617             // This will update the UI if needed
618             setShowDot(showDot());
619         }
620     }
621 
622     /**
623      * @return the icon set on BubbleMetadata, if it exists. This is only non-null for bubbles
624      * created via a PendingIntent. This is null for bubbles created by a shortcut, as we use the
625      * icon from the shortcut.
626      */
627     @Nullable
getIcon()628     public Icon getIcon() {
629         return mIcon;
630     }
631 
isTextChanged()632     boolean isTextChanged() {
633         return mIsTextChanged;
634     }
635 
636     /**
637      * @return the last time this bubble was updated or accessed, whichever is most recent.
638      */
getLastActivity()639     long getLastActivity() {
640         return isAppBubble() ? Long.MAX_VALUE : Math.max(mLastUpdated, mLastAccessed);
641     }
642 
643     /**
644      * Sets if the intent used for this bubble is currently active (i.e. populating an
645      * expanded view, expanded or not).
646      */
setIntentActive()647     void setIntentActive() {
648         mIntentActive = true;
649     }
650 
isIntentActive()651     boolean isIntentActive() {
652         return mIntentActive;
653     }
654 
getInstanceId()655     public InstanceId getInstanceId() {
656         return mInstanceId;
657     }
658 
659     @Nullable
getChannelId()660     public String getChannelId() {
661         return mChannelId;
662     }
663 
getNotificationId()664     public int getNotificationId() {
665         return mNotificationId;
666     }
667 
668     /**
669      * @return the task id of the task in which bubble contents is drawn.
670      */
671     @Override
getTaskId()672     public int getTaskId() {
673         if (mBubbleBarExpandedView != null) {
674             return mBubbleBarExpandedView.getTaskId();
675         }
676         return mExpandedView != null ? mExpandedView.getTaskId() : mTaskId;
677     }
678 
679     /**
680      * Should be invoked whenever a Bubble is accessed (selected while expanded).
681      */
markAsAccessedAt(long lastAccessedMillis)682     void markAsAccessedAt(long lastAccessedMillis) {
683         mLastAccessed = lastAccessedMillis;
684         setSuppressNotification(true);
685         setShowDot(false /* show */);
686     }
687 
688     /**
689      * Should be invoked whenever a Bubble is promoted from overflow.
690      */
markUpdatedAt(long lastAccessedMillis)691     void markUpdatedAt(long lastAccessedMillis) {
692         mLastUpdated = lastAccessedMillis;
693     }
694 
695     /**
696      * Whether this notification should be shown in the shade.
697      */
showInShade()698     boolean showInShade() {
699         return !shouldSuppressNotification() || !mIsDismissable;
700     }
701 
702     /**
703      * Whether this bubble is currently being hidden from the stack.
704      */
isSuppressed()705     boolean isSuppressed() {
706         return (mFlags & Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE) != 0;
707     }
708 
709     /**
710      * Whether this bubble is able to be suppressed (i.e. has the developer opted into the API to
711      * hide the bubble when in the same content).
712      */
isSuppressable()713     boolean isSuppressable() {
714         return (mFlags & Notification.BubbleMetadata.FLAG_SUPPRESSABLE_BUBBLE) != 0;
715     }
716 
717     /**
718      * Whether this notification conversation is important.
719      */
isImportantConversation()720     boolean isImportantConversation() {
721         return mIsImportantConversation;
722     }
723 
724     /**
725      * Whether this bubble is conversation
726      */
isConversation()727     public boolean isConversation() {
728         return null != mShortcutInfo;
729     }
730 
731     /**
732      * Sets whether this notification should be suppressed in the shade.
733      */
734     @VisibleForTesting
setSuppressNotification(boolean suppressNotification)735     public void setSuppressNotification(boolean suppressNotification) {
736         boolean prevShowInShade = showInShade();
737         if (suppressNotification) {
738             mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
739         } else {
740             mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
741         }
742 
743         if (showInShade() != prevShowInShade && mBubbleMetadataFlagListener != null) {
744             mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this);
745         }
746     }
747 
748     /**
749      * Sets whether this bubble should be suppressed from the stack.
750      */
setSuppressBubble(boolean suppressBubble)751     public void setSuppressBubble(boolean suppressBubble) {
752         if (!isSuppressable()) {
753             Log.e(TAG, "calling setSuppressBubble on "
754                     + getKey() + " when bubble not suppressable");
755             return;
756         }
757         boolean prevSuppressed = isSuppressed();
758         if (suppressBubble) {
759             mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE;
760         } else {
761             mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE;
762         }
763         if (prevSuppressed != suppressBubble && mBubbleMetadataFlagListener != null) {
764             mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this);
765         }
766     }
767 
768     /**
769      * Sets whether the bubble for this notification should show a dot indicating updated content.
770      */
setShowDot(boolean showDot)771     void setShowDot(boolean showDot) {
772         mShowBubbleUpdateDot = showDot;
773 
774         if (mIconView != null) {
775             mIconView.updateDotVisibility(true /* animate */);
776         }
777     }
778 
779     /**
780      * Whether the bubble for this notification should show a dot indicating updated content.
781      */
782     @Override
showDot()783     public boolean showDot() {
784         return mShowBubbleUpdateDot
785                 && !mShouldSuppressNotificationDot
786                 && !shouldSuppressNotification();
787     }
788 
789     /**
790      * Whether the flyout for the bubble should be shown.
791      */
792     @VisibleForTesting
showFlyout()793     public boolean showFlyout() {
794         return !mSuppressFlyout && !mShouldSuppressPeek
795                 && !shouldSuppressNotification()
796                 && !mShouldSuppressNotificationList;
797     }
798 
799     /**
800      * Set whether the flyout text for the bubble should be shown when an update is received.
801      *
802      * @param suppressFlyout whether the flyout text is shown
803      */
setSuppressFlyout(boolean suppressFlyout)804     void setSuppressFlyout(boolean suppressFlyout) {
805         mSuppressFlyout = suppressFlyout;
806     }
807 
getFlyoutMessage()808     FlyoutMessage getFlyoutMessage() {
809         return mFlyoutMessage;
810     }
811 
getRawDesiredHeight()812     int getRawDesiredHeight() {
813         return mDesiredHeight;
814     }
815 
getRawDesiredHeightResId()816     int getRawDesiredHeightResId() {
817         return mDesiredHeightResId;
818     }
819 
getDesiredHeight(Context context)820     float getDesiredHeight(Context context) {
821         boolean useRes = mDesiredHeightResId != 0;
822         if (useRes) {
823             return getDimenForPackageUser(context, mDesiredHeightResId, mPackageName,
824                     mUser.getIdentifier());
825         } else {
826             return mDesiredHeight * context.getResources().getDisplayMetrics().density;
827         }
828     }
829 
getDesiredHeightString()830     String getDesiredHeightString() {
831         boolean useRes = mDesiredHeightResId != 0;
832         if (useRes) {
833             return String.valueOf(mDesiredHeightResId);
834         } else {
835             return String.valueOf(mDesiredHeight);
836         }
837     }
838 
839     @Nullable
getBubbleIntent()840     PendingIntent getBubbleIntent() {
841         return mIntent;
842     }
843 
844     @Nullable
getDeleteIntent()845     PendingIntent getDeleteIntent() {
846         return mDeleteIntent;
847     }
848 
849     @Nullable
getAppBubbleIntent()850     Intent getAppBubbleIntent() {
851         return mAppIntent;
852     }
853 
854     /**
855      * Returns whether this bubble is from an app versus a notification.
856      */
isAppBubble()857     public boolean isAppBubble() {
858         return mIsAppBubble;
859     }
860 
861     /** Creates open app settings intent */
getSettingsIntent(final Context context)862     public Intent getSettingsIntent(final Context context) {
863         final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS);
864         intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
865         final int uid = getUid(context);
866         if (uid != -1) {
867             intent.putExtra(Settings.EXTRA_APP_UID, uid);
868         }
869         intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
870         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
871         intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
872         return intent;
873     }
874 
getAppUid()875     public int getAppUid() {
876         return mAppUid;
877     }
878 
getUid(final Context context)879     private int getUid(final Context context) {
880         if (mAppUid != -1) return mAppUid;
881         final PackageManager pm = BubbleController.getPackageManagerForUser(context,
882                 mUser.getIdentifier());
883         if (pm == null) return -1;
884         try {
885             final ApplicationInfo info = pm.getApplicationInfo(mShortcutInfo.getPackage(), 0);
886             return info.uid;
887         } catch (PackageManager.NameNotFoundException e) {
888             Log.e(TAG, "cannot find uid", e);
889         }
890         return -1;
891     }
892 
getDimenForPackageUser(Context context, int resId, String pkg, int userId)893     private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) {
894         Resources r;
895         if (pkg != null) {
896             try {
897                 if (userId == UserHandle.USER_ALL) {
898                     userId = UserHandle.USER_SYSTEM;
899                 }
900                 r = context.createContextAsUser(UserHandle.of(userId), /* flags */ 0)
901                         .getPackageManager().getResourcesForApplication(pkg);
902                 return r.getDimensionPixelSize(resId);
903             } catch (PackageManager.NameNotFoundException ex) {
904                 // Uninstalled, don't care
905             } catch (Resources.NotFoundException e) {
906                 // Invalid res id, return 0 and user our default
907                 Log.e(TAG, "Couldn't find desired height res id", e);
908             }
909         }
910         return 0;
911     }
912 
shouldSuppressNotification()913     private boolean shouldSuppressNotification() {
914         return isEnabled(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION);
915     }
916 
shouldAutoExpand()917     public boolean shouldAutoExpand() {
918         return isEnabled(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
919     }
920 
921     @VisibleForTesting
setShouldAutoExpand(boolean shouldAutoExpand)922     public void setShouldAutoExpand(boolean shouldAutoExpand) {
923         boolean prevAutoExpand = shouldAutoExpand();
924         if (shouldAutoExpand) {
925             enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
926         } else {
927             disable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
928         }
929         if (prevAutoExpand != shouldAutoExpand && mBubbleMetadataFlagListener != null) {
930             mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this);
931         }
932     }
933 
setIsBubble(final boolean isBubble)934     public void setIsBubble(final boolean isBubble) {
935         mIsBubble = isBubble;
936     }
937 
isBubble()938     public boolean isBubble() {
939         return mIsBubble;
940     }
941 
enable(int option)942     public void enable(int option) {
943         mFlags |= option;
944     }
945 
disable(int option)946     public void disable(int option) {
947         mFlags &= ~option;
948     }
949 
isEnabled(int option)950     public boolean isEnabled(int option) {
951         return (mFlags & option) != 0;
952     }
953 
getFlags()954     public int getFlags() {
955         return mFlags;
956     }
957 
958     @Override
toString()959     public String toString() {
960         return "Bubble{" + mKey + '}';
961     }
962 
963     /**
964      * Description of current bubble state.
965      */
dump(@onNull PrintWriter pw)966     public void dump(@NonNull PrintWriter pw) {
967         pw.print("key: "); pw.println(mKey);
968         pw.print("  showInShade:   "); pw.println(showInShade());
969         pw.print("  showDot:       "); pw.println(showDot());
970         pw.print("  showFlyout:    "); pw.println(showFlyout());
971         pw.print("  lastActivity:  "); pw.println(getLastActivity());
972         pw.print("  desiredHeight: "); pw.println(getDesiredHeightString());
973         pw.print("  suppressNotif: "); pw.println(shouldSuppressNotification());
974         pw.print("  autoExpand:    "); pw.println(shouldAutoExpand());
975         pw.print("  isDismissable: "); pw.println(mIsDismissable);
976         pw.println("  bubbleMetadataFlagListener null: " + (mBubbleMetadataFlagListener == null));
977         if (mExpandedView != null) {
978             mExpandedView.dump(pw);
979         }
980     }
981 
982     @Override
equals(Object o)983     public boolean equals(Object o) {
984         if (this == o) return true;
985         if (!(o instanceof Bubble)) return false;
986         Bubble bubble = (Bubble) o;
987         return Objects.equals(mKey, bubble.mKey);
988     }
989 
990     @Override
hashCode()991     public int hashCode() {
992         return Objects.hash(mKey);
993     }
994 
995     @Nullable
getTitle(@onNull final BubbleEntry e)996     private static String getTitle(@NonNull final BubbleEntry e) {
997         final CharSequence titleCharSeq = e.getStatusBarNotification()
998                 .getNotification().extras.getCharSequence(Notification.EXTRA_TITLE);
999         return titleCharSeq == null ? null : titleCharSeq.toString();
1000     }
1001 
1002     /**
1003      * Returns our best guess for the most relevant text summary of the latest update to this
1004      * notification, based on its type. Returns null if there should not be an update message.
1005      */
1006     @NonNull
extractFlyoutMessage(BubbleEntry entry)1007     static Bubble.FlyoutMessage extractFlyoutMessage(BubbleEntry entry) {
1008         Objects.requireNonNull(entry);
1009         final Notification underlyingNotif = entry.getStatusBarNotification().getNotification();
1010         final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle();
1011 
1012         Bubble.FlyoutMessage bubbleMessage = new Bubble.FlyoutMessage();
1013         bubbleMessage.isGroupChat = underlyingNotif.extras.getBoolean(
1014                 Notification.EXTRA_IS_GROUP_CONVERSATION);
1015         try {
1016             if (Notification.BigTextStyle.class.equals(style)) {
1017                 // Return the big text, it is big so probably important. If it's not there use the
1018                 // normal text.
1019                 CharSequence bigText =
1020                         underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT);
1021                 bubbleMessage.message = !TextUtils.isEmpty(bigText)
1022                         ? bigText
1023                         : underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
1024                 return bubbleMessage;
1025             } else if (Notification.MessagingStyle.class.equals(style)) {
1026                 final List<Notification.MessagingStyle.Message> messages =
1027                         Notification.MessagingStyle.Message.getMessagesFromBundleArray(
1028                                 (Parcelable[]) underlyingNotif.extras.get(
1029                                         Notification.EXTRA_MESSAGES));
1030 
1031                 final Notification.MessagingStyle.Message latestMessage =
1032                         Notification.MessagingStyle.findLatestIncomingMessage(messages);
1033                 if (latestMessage != null) {
1034                     bubbleMessage.message = latestMessage.getText();
1035                     Person sender = latestMessage.getSenderPerson();
1036                     bubbleMessage.senderName = sender != null ? sender.getName() : null;
1037                     bubbleMessage.senderAvatar = null;
1038                     bubbleMessage.senderIcon = sender != null ? sender.getIcon() : null;
1039                     return bubbleMessage;
1040                 }
1041             } else if (Notification.InboxStyle.class.equals(style)) {
1042                 CharSequence[] lines =
1043                         underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES);
1044 
1045                 // Return the last line since it should be the most recent.
1046                 if (lines != null && lines.length > 0) {
1047                     bubbleMessage.message = lines[lines.length - 1];
1048                     return bubbleMessage;
1049                 }
1050             } else if (Notification.MediaStyle.class.equals(style)) {
1051                 // Return nothing, media updates aren't typically useful as a text update.
1052                 return bubbleMessage;
1053             } else {
1054                 // Default to text extra.
1055                 bubbleMessage.message =
1056                         underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
1057                 return bubbleMessage;
1058             }
1059         } catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) {
1060             // No use crashing, we'll just return null and the caller will assume there's no update
1061             // message.
1062             e.printStackTrace();
1063         }
1064 
1065         return bubbleMessage;
1066     }
1067 }
1068