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