/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.wm.shell.bubbles; import static android.app.ActivityTaskManager.INVALID_TASK_ID; import static android.os.AsyncTask.Status.FINISHED; import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE; import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES; import android.annotation.DimenRes; import android.annotation.Hide; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Notification; import android.app.PendingIntent; import android.app.Person; import android.content.Context; import android.content.Intent; import android.content.LocusId; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ShortcutInfo; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Path; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Parcelable; import android.os.UserHandle; import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; import com.android.internal.protolog.common.ProtoLog; import com.android.launcher3.icons.BubbleIconFactory; import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView; import com.android.wm.shell.bubbles.bar.BubbleBarLayerView; import com.android.wm.shell.common.bubbles.BubbleInfo; import java.io.PrintWriter; import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; /** * Encapsulates the data and UI elements of a bubble. */ public class Bubble implements BubbleViewProvider { private static final String TAG = "Bubble"; /** A string suffix used in app bubbles' {@link #mKey}. */ public static final String KEY_APP_BUBBLE = "key_app_bubble"; /** Whether the bubble is an app bubble. */ private final boolean mIsAppBubble; private final String mKey; @Nullable private final String mGroupKey; @Nullable private final LocusId mLocusId; private final Executor mMainExecutor; private long mLastUpdated; private long mLastAccessed; @Nullable private Bubbles.BubbleMetadataFlagListener mBubbleMetadataFlagListener; /** Whether the bubble should show a dot for the notification indicating updated content. */ private boolean mShowBubbleUpdateDot = true; /** Whether flyout text should be suppressed, regardless of any other flags or state. */ private boolean mSuppressFlyout; // Items that are typically loaded later private String mAppName; private ShortcutInfo mShortcutInfo; private String mMetadataShortcutId; /** * If {@link BubbleController#isShowingAsBubbleBar()} is true, the only view that will be * populated will be {@link #mBubbleBarExpandedView}. If it is false, {@link #mIconView} * and {@link #mExpandedView} will be populated. */ @Nullable private BadgedImageView mIconView; @Nullable private BubbleExpandedView mExpandedView; @Nullable private BubbleBarExpandedView mBubbleBarExpandedView; @Nullable private BubbleTaskView mBubbleTaskView; private BubbleViewInfoTask mInflationTask; private boolean mInflateSynchronously; private boolean mPendingIntentCanceled; private boolean mIsImportantConversation; /** * Presentational info about the flyout. */ public static class FlyoutMessage { @Nullable public Icon senderIcon; @Nullable public Drawable senderAvatar; @Nullable public CharSequence senderName; @Nullable public CharSequence message; @Nullable public boolean isGroupChat; } private FlyoutMessage mFlyoutMessage; // The developer provided image for the bubble private Bitmap mBubbleBitmap; // The app badge for the bubble private Bitmap mBadgeBitmap; // App badge without any markings for important conversations private Bitmap mRawBadgeBitmap; private int mDotColor; private Path mDotPath; private int mFlags; @NonNull private UserHandle mUser; @NonNull private String mPackageName; @Nullable private String mTitle; @Nullable private Icon mIcon; private boolean mIsBubble; private boolean mIsTextChanged; private boolean mIsDismissable; private boolean mShouldSuppressNotificationDot; private boolean mShouldSuppressNotificationList; private boolean mShouldSuppressPeek; private int mDesiredHeight; @DimenRes private int mDesiredHeightResId; private int mTaskId; /** for logging **/ @Nullable private InstanceId mInstanceId; @Nullable private String mChannelId; private int mNotificationId; private int mAppUid = -1; /** * A bubble is created and can be updated. This intent is updated until the user first * expands the bubble. Once the user has expanded the contents, we ignore the intent updates * to prevent restarting the intent & possibly altering UI state in the activity in front of * the user. * * Once the bubble is overflowed, the activity is finished and updates to the * notification are respected. Typically an update to an overflowed bubble would result in * that bubble being added back to the stack anyways. */ @Nullable private PendingIntent mIntent; private boolean mIntentActive; @Nullable private PendingIntent.CancelListener mIntentCancelListener; /** * Sent when the bubble & notification are no longer visible to the user (i.e. no * notification in the shade, no bubble in the stack or overflow). */ @Nullable private PendingIntent mDeleteIntent; /** * Used only for a special bubble in the stack that has {@link #mIsAppBubble} set to true. * There can only be one of these bubbles in the stack and this intent will be populated for * that bubble. */ @Nullable private Intent mAppIntent; /** * Create a bubble with limited information based on given {@link ShortcutInfo}. * Note: Currently this is only being used when the bubble is persisted to disk. */ @VisibleForTesting(visibility = PRIVATE) public Bubble(@NonNull 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) { Objects.requireNonNull(key); Objects.requireNonNull(shortcutInfo); mMetadataShortcutId = shortcutInfo.getId(); mShortcutInfo = shortcutInfo; mKey = key; mGroupKey = null; mLocusId = locus != null ? new LocusId(locus) : null; mIsDismissable = isDismissable; mFlags = 0; mUser = shortcutInfo.getUserHandle(); mPackageName = shortcutInfo.getPackage(); mIcon = shortcutInfo.getIcon(); mDesiredHeight = desiredHeight; mDesiredHeightResId = desiredHeightResId; mTitle = title; mShowBubbleUpdateDot = false; mMainExecutor = mainExecutor; mTaskId = taskId; mBubbleMetadataFlagListener = listener; mIsAppBubble = false; } private Bubble( Intent intent, UserHandle user, @Nullable Icon icon, boolean isAppBubble, String key, Executor mainExecutor) { mGroupKey = null; mLocusId = null; mFlags = 0; mUser = user; mIcon = icon; mIsAppBubble = isAppBubble; mKey = key; mShowBubbleUpdateDot = false; mMainExecutor = mainExecutor; mTaskId = INVALID_TASK_ID; mAppIntent = intent; mDesiredHeight = Integer.MAX_VALUE; mPackageName = intent.getPackage(); } /** Creates an app bubble. */ public static Bubble createAppBubble( Intent intent, UserHandle user, @Nullable Icon icon, Executor mainExecutor) { return new Bubble(intent, user, icon, /* isAppBubble= */ true, /* key= */ getAppBubbleKeyForApp(intent.getPackage(), user), mainExecutor); } /** * Returns the key for an app bubble from an app with package name, {@code packageName} on an * Android user, {@code user}. */ public static String getAppBubbleKeyForApp(String packageName, UserHandle user) { Objects.requireNonNull(packageName); Objects.requireNonNull(user); return KEY_APP_BUBBLE + ":" + user.getIdentifier() + ":" + packageName; } @VisibleForTesting(visibility = PRIVATE) public Bubble(@NonNull final BubbleEntry entry, final Bubbles.BubbleMetadataFlagListener listener, final Bubbles.PendingIntentCanceledListener intentCancelListener, Executor mainExecutor) { mIsAppBubble = false; mKey = entry.getKey(); mGroupKey = entry.getGroupKey(); mLocusId = entry.getLocusId(); mBubbleMetadataFlagListener = listener; mIntentCancelListener = intent -> { if (mIntent != null) { mIntent.unregisterCancelListener(mIntentCancelListener); } mainExecutor.execute(() -> { intentCancelListener.onPendingIntentCanceled(this); }); }; mMainExecutor = mainExecutor; mTaskId = INVALID_TASK_ID; setEntry(entry); } /** Converts this bubble into a {@link BubbleInfo} object to be shared with external callers. */ public BubbleInfo asBubbleBarBubble() { return new BubbleInfo(getKey(), getFlags(), getShortcutId(), getIcon(), getUser().getIdentifier(), getPackageName(), getTitle(), getAppName(), isImportantConversation()); } @Override public String getKey() { return mKey; } @Hide public boolean isDismissable() { return mIsDismissable; } /** * @see StatusBarNotification#getGroupKey() * @return the group key for this bubble, if one exists. */ public String getGroupKey() { return mGroupKey; } public LocusId getLocusId() { return mLocusId; } public UserHandle getUser() { return mUser; } @NonNull public String getPackageName() { return mPackageName; } @Override public Bitmap getBubbleIcon() { return mBubbleBitmap; } @Override public Bitmap getAppBadge() { return mBadgeBitmap; } @Override public Bitmap getRawAppBadge() { return mRawBadgeBitmap; } @Override public int getDotColor() { return mDotColor; } @Override public Path getDotPath() { return mDotPath; } @Nullable public String getAppName() { return mAppName; } @Nullable public ShortcutInfo getShortcutInfo() { return mShortcutInfo; } @Nullable @Override public BadgedImageView getIconView() { return mIconView; } @Nullable @Override public BubbleExpandedView getExpandedView() { return mExpandedView; } @Nullable @Override public BubbleBarExpandedView getBubbleBarExpandedView() { return mBubbleBarExpandedView; } @Nullable public String getTitle() { return mTitle; } /** * Returns the existing {@link #mBubbleTaskView} if it's not {@code null}. Otherwise a new * instance of {@link BubbleTaskView} is created. */ public BubbleTaskView getOrCreateBubbleTaskView(BubbleTaskViewFactory taskViewFactory) { if (mBubbleTaskView == null) { mBubbleTaskView = taskViewFactory.create(); } return mBubbleTaskView; } /** * @return the ShortcutInfo id if it exists, or the metadata shortcut id otherwise. */ String getShortcutId() { return getShortcutInfo() != null ? getShortcutInfo().getId() : getMetadataShortcutId(); } String getMetadataShortcutId() { return mMetadataShortcutId; } boolean hasMetadataShortcutId() { return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty()); } /** * Call this to clean up the task for the bubble. Ensure this is always called when done with * the bubble. */ void cleanupExpandedView() { cleanupExpandedView(true); } private void cleanupExpandedView(boolean cleanupTaskView) { if (mExpandedView != null) { mExpandedView.cleanUpExpandedState(); mExpandedView = null; } if (mBubbleBarExpandedView != null) { mBubbleBarExpandedView.cleanUpExpandedState(); mBubbleBarExpandedView = null; } if (cleanupTaskView) { cleanupTaskView(); } if (mIntent != null) { mIntent.unregisterCancelListener(mIntentCancelListener); } mIntentActive = false; } private void cleanupTaskView() { if (mBubbleTaskView != null) { mBubbleTaskView.cleanup(); mBubbleTaskView = null; } } /** * Call when all the views should be removed/cleaned up. */ public void cleanupViews() { ProtoLog.d(WM_SHELL_BUBBLES, "Bubble#cleanupViews=%s", getKey()); cleanupViews(true); } /** * Call when all the views should be removed/cleaned up. * *

If we're switching between bar and floating modes, pass {@code false} on * {@code cleanupTaskView} to avoid recreating it in the new mode. */ void cleanupViews(boolean cleanupTaskView) { cleanupExpandedView(cleanupTaskView); mIconView = null; } void setPendingIntentCanceled() { mPendingIntentCanceled = true; } boolean getPendingIntentCanceled() { return mPendingIntentCanceled; } /** * Sets whether to perform inflation on the same thread as the caller. This method should only * be used in tests, not in production. */ @VisibleForTesting void setInflateSynchronously(boolean inflateSynchronously) { mInflateSynchronously = inflateSynchronously; } /** * Sets whether this bubble is considered text changed. This method is purely for * testing. */ @VisibleForTesting void setTextChangedForTest(boolean textChanged) { mIsTextChanged = textChanged; } /** * Starts a task to inflate & load any necessary information to display a bubble. * * @param callback the callback to notify one the bubble is ready to be displayed. * @param context the context for the bubble. * @param expandedViewManager the bubble expanded view manager. * @param taskViewFactory the task view factory used to create the task view for the bubble. * @param positioner the bubble positioner. * @param stackView the view the bubble is added to, iff showing as floating. * @param layerView the layer the bubble is added to, iff showing in the bubble bar. * @param iconFactory the icon factory used to create images for the bubble. */ void inflate(BubbleViewInfoTask.Callback callback, Context context, BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory taskViewFactory, BubblePositioner positioner, @Nullable BubbleStackView stackView, @Nullable BubbleBarLayerView layerView, BubbleIconFactory iconFactory, boolean skipInflation) { if (isBubbleLoading()) { mInflationTask.cancel(true /* mayInterruptIfRunning */); } mInflationTask = new BubbleViewInfoTask(this, context, expandedViewManager, taskViewFactory, positioner, stackView, layerView, iconFactory, skipInflation, callback, mMainExecutor); if (mInflateSynchronously) { mInflationTask.onPostExecute(mInflationTask.doInBackground()); } else { mInflationTask.execute(); } } private boolean isBubbleLoading() { return mInflationTask != null && mInflationTask.getStatus() != FINISHED; } boolean isInflated() { return (mIconView != null && mExpandedView != null) || mBubbleBarExpandedView != null; } void stopInflation() { if (mInflationTask == null) { return; } mInflationTask.cancel(true /* mayInterruptIfRunning */); } void setViewInfo(BubbleViewInfoTask.BubbleViewInfo info) { if (!isInflated()) { mIconView = info.imageView; mExpandedView = info.expandedView; mBubbleBarExpandedView = info.bubbleBarExpandedView; } mShortcutInfo = info.shortcutInfo; mAppName = info.appName; if (mTitle == null) { mTitle = mAppName; } mFlyoutMessage = info.flyoutMessage; mBadgeBitmap = info.badgeBitmap; mRawBadgeBitmap = info.rawBadgeBitmap; mBubbleBitmap = info.bubbleBitmap; mDotColor = info.dotColor; mDotPath = info.dotPath; if (mExpandedView != null) { mExpandedView.update(this /* bubble */); } if (mBubbleBarExpandedView != null) { mBubbleBarExpandedView.update(this /* bubble */); } if (mIconView != null) { mIconView.setRenderedBubble(this /* bubble */); } } /** * Set visibility of bubble in the expanded state. * *

Note that this contents visibility doesn't affect visibility at {@link android.view.View}, * and setting {@code false} actually means rendering the expanded view in transparent. * * @param visibility {@code true} if the expanded bubble should be visible on the screen. */ @Override public void setTaskViewVisibility(boolean visibility) { if (mExpandedView != null) { mExpandedView.setContentVisibility(visibility); } } /** * Sets the entry associated with this bubble. */ void setEntry(@NonNull final BubbleEntry entry) { Objects.requireNonNull(entry); boolean showingDotPreviously = showDot(); mLastUpdated = entry.getStatusBarNotification().getPostTime(); mIsBubble = entry.getStatusBarNotification().getNotification().isBubbleNotification(); mPackageName = entry.getStatusBarNotification().getPackageName(); mUser = entry.getStatusBarNotification().getUser(); mTitle = getTitle(entry); mChannelId = entry.getStatusBarNotification().getNotification().getChannelId(); mNotificationId = entry.getStatusBarNotification().getId(); mAppUid = entry.getStatusBarNotification().getUid(); mInstanceId = entry.getStatusBarNotification().getInstanceId(); mFlyoutMessage = extractFlyoutMessage(entry); if (entry.getRanking() != null) { mShortcutInfo = entry.getRanking().getConversationShortcutInfo(); mIsTextChanged = entry.getRanking().isTextChanged(); if (entry.getRanking().getChannel() != null) { mIsImportantConversation = entry.getRanking().getChannel().isImportantConversation(); } } if (entry.getBubbleMetadata() != null) { mMetadataShortcutId = entry.getBubbleMetadata().getShortcutId(); mFlags = entry.getBubbleMetadata().getFlags(); mDesiredHeight = entry.getBubbleMetadata().getDesiredHeight(); mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId(); mIcon = entry.getBubbleMetadata().getIcon(); if (!mIntentActive || mIntent == null) { if (mIntent != null) { mIntent.unregisterCancelListener(mIntentCancelListener); } mIntent = entry.getBubbleMetadata().getIntent(); if (mIntent != null) { mIntent.registerCancelListener(mIntentCancelListener); } } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) { // Was an intent bubble now it's a shortcut bubble... still unregister the listener mIntent.unregisterCancelListener(mIntentCancelListener); mIntentActive = false; mIntent = null; } mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent(); } mIsDismissable = entry.isDismissable(); mShouldSuppressNotificationDot = entry.shouldSuppressNotificationDot(); mShouldSuppressNotificationList = entry.shouldSuppressNotificationList(); mShouldSuppressPeek = entry.shouldSuppressPeek(); if (showingDotPreviously != showDot()) { // This will update the UI if needed setShowDot(showDot()); } } /** * @return the icon set on BubbleMetadata, if it exists. This is only non-null for bubbles * created via a PendingIntent. This is null for bubbles created by a shortcut, as we use the * icon from the shortcut. */ @Nullable public Icon getIcon() { return mIcon; } boolean isTextChanged() { return mIsTextChanged; } /** * @return the last time this bubble was updated or accessed, whichever is most recent. */ long getLastActivity() { return Math.max(mLastUpdated, mLastAccessed); } /** * Sets if the intent used for this bubble is currently active (i.e. populating an * expanded view, expanded or not). */ void setIntentActive() { mIntentActive = true; } boolean isIntentActive() { return mIntentActive; } public InstanceId getInstanceId() { return mInstanceId; } @Nullable public String getChannelId() { return mChannelId; } public int getNotificationId() { return mNotificationId; } /** * @return the task id of the task in which bubble contents is drawn. */ @Override public int getTaskId() { if (mBubbleBarExpandedView != null) { return mBubbleBarExpandedView.getTaskId(); } return mExpandedView != null ? mExpandedView.getTaskId() : mTaskId; } /** * Should be invoked whenever a Bubble is accessed (selected while expanded). */ void markAsAccessedAt(long lastAccessedMillis) { mLastAccessed = lastAccessedMillis; setSuppressNotification(true); setShowDot(false /* show */); } /** * Should be invoked whenever a Bubble is promoted from overflow. */ void markUpdatedAt(long lastAccessedMillis) { mLastUpdated = lastAccessedMillis; } /** * Whether this notification should be shown in the shade. */ boolean showInShade() { return !shouldSuppressNotification() || !mIsDismissable; } /** * Whether this bubble is currently being hidden from the stack. */ boolean isSuppressed() { return (mFlags & Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE) != 0; } /** * Whether this bubble is able to be suppressed (i.e. has the developer opted into the API to * hide the bubble when in the same content). */ boolean isSuppressable() { return (mFlags & Notification.BubbleMetadata.FLAG_SUPPRESSABLE_BUBBLE) != 0; } /** * Whether this notification conversation is important. */ boolean isImportantConversation() { return mIsImportantConversation; } /** * Whether this bubble is conversation */ public boolean isConversation() { return null != mShortcutInfo; } /** * Sets whether this notification should be suppressed in the shade. */ @VisibleForTesting public void setSuppressNotification(boolean suppressNotification) { boolean prevShowInShade = showInShade(); if (suppressNotification) { mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION; } else { mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION; } if (showInShade() != prevShowInShade && mBubbleMetadataFlagListener != null) { mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this); } } /** * Sets whether this bubble should be suppressed from the stack. */ public void setSuppressBubble(boolean suppressBubble) { if (!isSuppressable()) { Log.e(TAG, "calling setSuppressBubble on " + getKey() + " when bubble not suppressable"); return; } boolean prevSuppressed = isSuppressed(); if (suppressBubble) { mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE; } else { mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_BUBBLE; } if (prevSuppressed != suppressBubble && mBubbleMetadataFlagListener != null) { mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this); } } /** * Sets whether the bubble for this notification should show a dot indicating updated content. */ void setShowDot(boolean showDot) { mShowBubbleUpdateDot = showDot; if (mIconView != null) { mIconView.updateDotVisibility(true /* animate */); } } /** * Whether the bubble for this notification should show a dot indicating updated content. */ @Override public boolean showDot() { return mShowBubbleUpdateDot && !mShouldSuppressNotificationDot && !shouldSuppressNotification(); } /** * Whether the flyout for the bubble should be shown. */ @VisibleForTesting public boolean showFlyout() { return !mSuppressFlyout && !mShouldSuppressPeek && !shouldSuppressNotification() && !mShouldSuppressNotificationList; } /** * Set whether the flyout text for the bubble should be shown when an update is received. * * @param suppressFlyout whether the flyout text is shown */ void setSuppressFlyout(boolean suppressFlyout) { mSuppressFlyout = suppressFlyout; } FlyoutMessage getFlyoutMessage() { return mFlyoutMessage; } int getRawDesiredHeight() { return mDesiredHeight; } int getRawDesiredHeightResId() { return mDesiredHeightResId; } float getDesiredHeight(Context context) { boolean useRes = mDesiredHeightResId != 0; if (useRes) { return getDimenForPackageUser(context, mDesiredHeightResId, mPackageName, mUser.getIdentifier()); } else { return mDesiredHeight * context.getResources().getDisplayMetrics().density; } } String getDesiredHeightString() { boolean useRes = mDesiredHeightResId != 0; if (useRes) { return String.valueOf(mDesiredHeightResId); } else { return String.valueOf(mDesiredHeight); } } @Nullable PendingIntent getBubbleIntent() { return mIntent; } @Nullable PendingIntent getDeleteIntent() { return mDeleteIntent; } @Nullable Intent getAppBubbleIntent() { return mAppIntent; } /** * Returns whether this bubble is from an app versus a notification. */ public boolean isAppBubble() { return mIsAppBubble; } /** Creates open app settings intent */ public Intent getSettingsIntent(final Context context) { final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS); intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); final int uid = getUid(context); if (uid != -1) { intent.putExtra(Settings.EXTRA_APP_UID, uid); } intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); return intent; } public int getAppUid() { return mAppUid; } private int getUid(final Context context) { if (mAppUid != -1) return mAppUid; final PackageManager pm = BubbleController.getPackageManagerForUser(context, mUser.getIdentifier()); if (pm == null) return -1; try { final ApplicationInfo info = pm.getApplicationInfo(mShortcutInfo.getPackage(), 0); return info.uid; } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "cannot find uid", e); } return -1; } private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) { Resources r; if (pkg != null) { try { if (userId == UserHandle.USER_ALL) { userId = UserHandle.USER_SYSTEM; } r = context.createContextAsUser(UserHandle.of(userId), /* flags */ 0) .getPackageManager().getResourcesForApplication(pkg); return r.getDimensionPixelSize(resId); } catch (PackageManager.NameNotFoundException ex) { // Uninstalled, don't care } catch (Resources.NotFoundException e) { // Invalid res id, return 0 and user our default Log.e(TAG, "Couldn't find desired height res id", e); } } return 0; } private boolean shouldSuppressNotification() { return isEnabled(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); } public boolean shouldAutoExpand() { return isEnabled(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); } @VisibleForTesting public void setShouldAutoExpand(boolean shouldAutoExpand) { boolean prevAutoExpand = shouldAutoExpand(); if (shouldAutoExpand) { enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); } else { disable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); } if (prevAutoExpand != shouldAutoExpand && mBubbleMetadataFlagListener != null) { mBubbleMetadataFlagListener.onBubbleMetadataFlagChanged(this); } } public void setIsBubble(final boolean isBubble) { mIsBubble = isBubble; } public boolean isBubble() { return mIsBubble; } public void enable(int option) { mFlags |= option; } public void disable(int option) { mFlags &= ~option; } public boolean isEnabled(int option) { return (mFlags & option) != 0; } public int getFlags() { return mFlags; } @Override public String toString() { return "Bubble{" + mKey + '}'; } /** * Description of current bubble state. */ public void dump(@NonNull PrintWriter pw) { pw.print("key: "); pw.println(mKey); pw.print(" showInShade: "); pw.println(showInShade()); pw.print(" showDot: "); pw.println(showDot()); pw.print(" showFlyout: "); pw.println(showFlyout()); pw.print(" lastActivity: "); pw.println(getLastActivity()); pw.print(" desiredHeight: "); pw.println(getDesiredHeightString()); pw.print(" suppressNotif: "); pw.println(shouldSuppressNotification()); pw.print(" autoExpand: "); pw.println(shouldAutoExpand()); pw.print(" isDismissable: "); pw.println(mIsDismissable); pw.println(" bubbleMetadataFlagListener null?: " + (mBubbleMetadataFlagListener == null)); if (mExpandedView != null) { mExpandedView.dump(pw, " "); } } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Bubble)) return false; Bubble bubble = (Bubble) o; return Objects.equals(mKey, bubble.mKey); } @Override public int hashCode() { return Objects.hash(mKey); } @Nullable private static String getTitle(@NonNull final BubbleEntry e) { final CharSequence titleCharSeq = e.getStatusBarNotification() .getNotification().extras.getCharSequence(Notification.EXTRA_TITLE); return titleCharSeq == null ? null : titleCharSeq.toString(); } /** * Returns our best guess for the most relevant text summary of the latest update to this * notification, based on its type. Returns null if there should not be an update message. */ @NonNull static Bubble.FlyoutMessage extractFlyoutMessage(BubbleEntry entry) { Objects.requireNonNull(entry); final Notification underlyingNotif = entry.getStatusBarNotification().getNotification(); final Class style = underlyingNotif.getNotificationStyle(); Bubble.FlyoutMessage bubbleMessage = new Bubble.FlyoutMessage(); bubbleMessage.isGroupChat = underlyingNotif.extras.getBoolean( Notification.EXTRA_IS_GROUP_CONVERSATION); try { if (Notification.BigTextStyle.class.equals(style)) { // Return the big text, it is big so probably important. If it's not there use the // normal text. CharSequence bigText = underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT); bubbleMessage.message = !TextUtils.isEmpty(bigText) ? bigText : underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT); return bubbleMessage; } else if (Notification.MessagingStyle.class.equals(style)) { final List messages = Notification.MessagingStyle.Message.getMessagesFromBundleArray( (Parcelable[]) underlyingNotif.extras.get( Notification.EXTRA_MESSAGES)); final Notification.MessagingStyle.Message latestMessage = Notification.MessagingStyle.findLatestIncomingMessage(messages); if (latestMessage != null) { bubbleMessage.message = latestMessage.getText(); Person sender = latestMessage.getSenderPerson(); bubbleMessage.senderName = sender != null ? sender.getName() : null; bubbleMessage.senderAvatar = null; bubbleMessage.senderIcon = sender != null ? sender.getIcon() : null; return bubbleMessage; } } else if (Notification.InboxStyle.class.equals(style)) { CharSequence[] lines = underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES); // Return the last line since it should be the most recent. if (lines != null && lines.length > 0) { bubbleMessage.message = lines[lines.length - 1]; return bubbleMessage; } } else if (Notification.MediaStyle.class.equals(style)) { // Return nothing, media updates aren't typically useful as a text update. return bubbleMessage; } else { // Default to text extra. bubbleMessage.message = underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT); return bubbleMessage; } } catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) { // No use crashing, we'll just return null and the caller will assume there's no update // message. e.printStackTrace(); } return bubbleMessage; } }