Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ba5ab51b authored by Mark Renouf's avatar Mark Renouf
Browse files

Updates BubbleData sorting and grouping to spec

This change adds onBubbleOrderChanged to BubbleStackView
to try to keep the order synchronized. There are some known
issues with the animation and visual state, which will be
addressed in a follow up CL.

Bug: 123542488
Test: BubbleControllerTest BubbleDataTest
Change-Id: Ie5a679df2f3069236f4d67a3fce4189b39b9eb28
parent cbe933a4
Loading
Loading
Loading
Loading
+18 −1
Original line number Diff line number Diff line
@@ -46,7 +46,7 @@ class Bubble {
    private long mLastUpdated;
    private long mLastAccessed;

    private static String groupId(NotificationEntry entry) {
    public static String groupId(NotificationEntry entry) {
        UserHandle user = entry.notification.getUser();
        return user.getIdentifier() + "|" + entry.notification.getPackageName();
    }
@@ -120,10 +120,27 @@ class Bubble {
        }
    }

    /**
     * @return the newer of {@link #getLastUpdateTime()} and {@link #getLastAccessTime()}
     */
    public long getLastActivity() {
        return Math.max(mLastUpdated, mLastAccessed);
    }

    /**
     * @return the timestamp in milliseconds of the most recent notification entry for this bubble
     */
    public long getLastUpdateTime() {
        return mLastUpdated;
    }

    /**
     * @return the timestamp in milliseconds when this bubble was last displayed in expanded state
     */
    public long getLastAccessTime() {
        return mLastAccessed;
    }

    /**
     * Should be invoked whenever a Bubble is accessed (selected while expanded).
     */
+9 −8
Original line number Diff line number Diff line
@@ -29,6 +29,9 @@ import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static com.android.systemui.statusbar.StatusBarState.SHADE;
import static com.android.systemui.statusbar.notification.NotificationEntryManager.UNDEFINED_DISMISS_REASON;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.SOURCE;

import android.annotation.Nullable;
@@ -73,6 +76,7 @@ import com.android.systemui.statusbar.phone.StatusBarWindowController;
import com.android.systemui.statusbar.policy.ConfigurationController;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.List;

import javax.inject.Inject;
@@ -88,11 +92,12 @@ import javax.inject.Singleton;
public class BubbleController implements ConfigurationController.ConfigurationListener {

    private static final String TAG = "BubbleController";
    private static final boolean DEBUG = true;
    private static final boolean DEBUG = false;

    @Retention(SOURCE)
    @IntDef({DISMISS_USER_GESTURE, DISMISS_AGED, DISMISS_TASK_FINISHED, DISMISS_BLOCKED,
            DISMISS_NOTIF_CANCEL, DISMISS_ACCESSIBILITY_ACTION, DISMISS_NO_LONGER_BUBBLE})
    @Target({FIELD, LOCAL_VARIABLE, PARAMETER})
    @interface DismissReason {}

    static final int DISMISS_USER_GESTURE = 1;
@@ -510,6 +515,9 @@ public class BubbleController implements ConfigurationController.ConfigurationLi

        @Override
        public void onOrderChanged(List<Bubble> bubbles) {
            if (mStackView != null) {
                mStackView.updateBubbleOrder(bubbles);
            }
        }

        @Override
@@ -526,13 +534,6 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
            }
        }

        @Override
        public void showFlyoutText(Bubble bubble, String text) {
            if (mStackView != null) {
                mStackView.animateInFlyoutForBubble(bubble);
            }
        }

        @Override
        public void apply() {
            mNotificationEntryManager.updateNotifications();
+193 −126
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.util.Log;
import android.util.Pair;

import androidx.annotation.Nullable;

@@ -53,10 +54,10 @@ public class BubbleData {

    private static final int MAX_BUBBLES = 5;

    private static final Comparator<Bubble> BUBBLES_BY_LAST_ACTIVITY_DESCENDING =
            Comparator.comparing(Bubble::getLastActivity).reversed();
    private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
            Comparator.comparing(BubbleData::sortKey).reversed();

    private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_LAST_ACTIVITY_DESCENDING =
    private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_MAX_SORT_KEY_DESCENDING =
            Comparator.<Map.Entry<String, Long>, Long>comparing(Map.Entry::getValue).reversed();

    /**
@@ -105,9 +106,6 @@ public class BubbleData {
         */
        void onExpandedChanged(boolean expanded);

        /** Flyout text should animate in, showing the given text. */
        void showFlyoutText(Bubble bubble, String text);

        /** Commit any pending operations (since last call of apply()) */
        void apply();
    }
@@ -121,15 +119,19 @@ public class BubbleData {
    private Bubble mSelectedBubble;
    private boolean mExpanded;

    // TODO: ensure this is invalidated at the appropriate time
    private int mSelectedBubbleExpandedPosition = -1;
    // State tracked during an operation -- keeps track of what listener events to dispatch.
    private boolean mExpandedChanged;
    private boolean mOrderChanged;
    private boolean mSelectionChanged;
    private Bubble mUpdatedBubble;
    private Bubble mAddedBubble;
    private final List<Pair<Bubble, Integer>> mRemovedBubbles = new ArrayList<>();

    private TimeSource mTimeSource = System::currentTimeMillis;

    @Nullable
    private Listener mListener;

    @VisibleForTesting
    @Inject
    public BubbleData(Context context) {
        mContext = context;
@@ -154,18 +156,19 @@ public class BubbleData {
    }

    public void setExpanded(boolean expanded) {
        if (setExpandedInternal(expanded)) {
            dispatchApply();
        if (DEBUG) {
            Log.d(TAG, "setExpanded: " + expanded);
        }
        setExpandedInternal(expanded);
        dispatchPendingChanges();
    }

    public void setSelectedBubble(Bubble bubble) {
        if (DEBUG) {
            Log.d(TAG, "setSelectedBubble: " + bubble);
        }
        if (setSelectedBubbleInternal(bubble)) {
            dispatchApply();
        }
        setSelectedBubbleInternal(bubble);
        dispatchPendingChanges();
    }

    public void notificationEntryUpdated(NotificationEntry entry) {
@@ -177,12 +180,12 @@ public class BubbleData {
            // Create a new bubble
            bubble = new Bubble(entry, this::onBubbleBlocked);
            doAdd(bubble);
            dispatchOnBubbleAdded(bubble);
            trim();
        } else {
            // Updates an existing bubble
            bubble.setEntry(entry);
            doUpdate(bubble);
            dispatchOnBubbleUpdated(bubble);
            mUpdatedBubble = bubble;
        }
        if (shouldAutoExpand(entry)) {
            setSelectedBubbleInternal(bubble);
@@ -192,7 +195,15 @@ public class BubbleData {
        } else if (mSelectedBubble == null) {
            setSelectedBubbleInternal(bubble);
        }
        dispatchApply();
        dispatchPendingChanges();
    }

    public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
        if (DEBUG) {
            Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
        }
        doRemove(entry.key, reason);
        dispatchPendingChanges();
    }

    private void doAdd(Bubble bubble) {
@@ -202,14 +213,21 @@ public class BubbleData {
        int minInsertPoint = 0;
        boolean newGroup = !hasBubbleWithGroupId(bubble.getGroupId());
        if (isExpanded()) {
            // first bubble of a group goes to the end, otherwise it goes within the existing group
            minInsertPoint =
                    newGroup ? mBubbles.size() : findFirstIndexForGroup(bubble.getGroupId());
            // first bubble of a group goes to the beginning, otherwise within the existing group
            minInsertPoint = newGroup ? 0 : findFirstIndexForGroup(bubble.getGroupId());
        }
        if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
            mOrderChanged = true;
        }
        insertBubble(minInsertPoint, bubble);
        mAddedBubble = bubble;
        if (!isExpanded()) {
            packGroup(findFirstIndexForGroup(bubble.getGroupId()));
            mOrderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
            // Top bubble becomes selected.
            setSelectedBubbleInternal(mBubbles.get(0));
        }
    }

    private void trim() {
        if (mBubbles.size() > MAX_BUBBLES) {
            mBubbles.stream()
                    // sort oldest first (ascending lastActivity)
@@ -217,10 +235,7 @@ public class BubbleData {
                    // skip the selected bubble
                    .filter((b) -> !b.equals(mSelectedBubble))
                    .findFirst()
                    .ifPresent((b) -> {
                        doRemove(b.getKey(), BubbleController.DISMISS_AGED);
                        dispatchApply();
                    });
                    .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
        }
    }

@@ -229,32 +244,38 @@ public class BubbleData {
            Log.d(TAG, "doUpdate: " + bubble);
        }
        if (!isExpanded()) {
            // while collapsed, update causes re-sort
            // while collapsed, update causes re-pack
            int prevPos = mBubbles.indexOf(bubble);
            mBubbles.remove(bubble);
            insertBubble(0, bubble);
            packGroup(findFirstIndexForGroup(bubble.getGroupId()));
            int newPos = insertBubble(0, bubble);
            if (prevPos != newPos) {
                packGroup(newPos);
                mOrderChanged = true;
            }
            setSelectedBubbleInternal(mBubbles.get(0));
        }

    public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
        if (DEBUG) {
            Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
        }
        doRemove(entry.key, reason);
        dispatchApply();
    }

    private void doRemove(String key, @DismissReason int reason) {
        int indexToRemove = indexForKey(key);
        if (indexToRemove >= 0) {
        if (indexToRemove == -1) {
            return;
        }
        Bubble bubbleToRemove = mBubbles.get(indexToRemove);
        if (mBubbles.size() == 1) {
            // Going to become empty, handle specially.
            setExpandedInternal(false);
            setSelectedBubbleInternal(null);
        }
        if (indexToRemove < mBubbles.size() - 1) {
            // Removing anything but the last bubble means positions will change.
            mOrderChanged = true;
        }
        mBubbles.remove(indexToRemove);
            dispatchOnBubbleRemoved(bubbleToRemove, reason);
        mRemovedBubbles.add(Pair.create(bubbleToRemove, reason));
        if (!isExpanded()) {
            mOrderChanged |= repackAll();
        }

        // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
        if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
@@ -266,7 +287,6 @@ public class BubbleData {
        bubbleToRemove.setDismissed();
        maybeSendDeleteIntent(reason, bubbleToRemove.entry);
    }
    }

    public void dismissAll(@DismissReason int reason) {
        if (DEBUG) {
@@ -281,56 +301,71 @@ public class BubbleData {
            Bubble bubble = mBubbles.remove(0);
            bubble.setDismissed();
            maybeSendDeleteIntent(reason, bubble.entry);
            dispatchOnBubbleRemoved(bubble, reason);
            mRemovedBubbles.add(Pair.create(bubble, reason));
        }
        dispatchApply();
        dispatchPendingChanges();
    }

    private void dispatchApply() {
        if (mListener != null) {
            mListener.apply();
        }
    }

    private void dispatchOnBubbleAdded(Bubble bubble) {
        if (mListener != null) {
            mListener.onBubbleAdded(bubble);
        }
    private void dispatchPendingChanges() {
        if (mListener == null) {
            mExpandedChanged = false;
            mAddedBubble = null;
            mSelectionChanged = false;
            mRemovedBubbles.clear();
            mUpdatedBubble = null;
            mOrderChanged = false;
            return;
        }
        boolean anythingChanged = false;

    private void dispatchOnBubbleRemoved(Bubble bubble, @DismissReason int reason) {
        if (mListener != null) {
            mListener.onBubbleRemoved(bubble, reason);
        }
        if (mAddedBubble != null) {
            mListener.onBubbleAdded(mAddedBubble);
            mAddedBubble = null;
            anythingChanged = true;
        }

    private void dispatchOnExpandedChanged(boolean expanded) {
        if (mListener != null) {
            mListener.onExpandedChanged(expanded);
        // Compat workaround: Always collapse first.
        if (mExpandedChanged && !mExpanded) {
            mListener.onExpandedChanged(mExpanded);
            mExpandedChanged = false;
            anythingChanged = true;
        }

        if (mSelectionChanged) {
            mListener.onSelectionChanged(mSelectedBubble);
            mSelectionChanged = false;
            anythingChanged = true;
        }

    private void dispatchOnSelectionChanged(@Nullable Bubble bubble) {
        if (mListener != null) {
            mListener.onSelectionChanged(bubble);
        if (!mRemovedBubbles.isEmpty()) {
            for (Pair<Bubble, Integer> removed : mRemovedBubbles) {
                mListener.onBubbleRemoved(removed.first, removed.second);
            }
            mRemovedBubbles.clear();
            anythingChanged = true;
        }

    private void dispatchOnBubbleUpdated(Bubble bubble) {
        if (mListener != null) {
            mListener.onBubbleUpdated(bubble);
        }
        if (mUpdatedBubble != null) {
            mListener.onBubbleUpdated(mUpdatedBubble);
            mUpdatedBubble = null;
            anythingChanged = true;
        }

    private void dispatchOnOrderChanged(List<Bubble> bubbles) {
        if (mListener != null) {
            mListener.onOrderChanged(bubbles);
        if (mOrderChanged) {
            mListener.onOrderChanged(mBubbles);
            mOrderChanged = false;
            anythingChanged = true;
        }

        if (mExpandedChanged) {
            mListener.onExpandedChanged(mExpanded);
            mExpandedChanged = false;
            anythingChanged = true;
        }

    private void dispatchShowFlyoutText(Bubble bubble, String text) {
        if (mListener != null) {
            mListener.showFlyoutText(bubble, text);
        if (anythingChanged) {
            mListener.apply();
        }
    }

@@ -339,29 +374,25 @@ public class BubbleData {
     * the value changes.
     *
     * @param bubble the new selected bubble
     * @return true if the state changed as a result
     */
    private boolean setSelectedBubbleInternal(@Nullable Bubble bubble) {
    private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
        if (DEBUG) {
            Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
        }
        if (Objects.equals(bubble, mSelectedBubble)) {
            return false;
            return;
        }
        if (bubble != null && !mBubbles.contains(bubble)) {
            Log.e(TAG, "Cannot select bubble which doesn't exist!"
                    + " (" + bubble + ") bubbles=" + mBubbles);
            return false;
            return;
        }
        if (mExpanded && bubble != null) {
            bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
        }
        mSelectedBubble = bubble;
        dispatchOnSelectionChanged(mSelectedBubble);
        if (!mExpanded || mSelectedBubble == null) {
            mSelectedBubbleExpandedPosition = -1;
        }
        return true;
        mSelectionChanged = true;
        return;
    }

    /**
@@ -369,37 +400,53 @@ public class BubbleData {
     * the value changes.
     *
     * @param shouldExpand the new requested state
     * @return true if the state changed as a result
     */
    private boolean setExpandedInternal(boolean shouldExpand) {
    private void setExpandedInternal(boolean shouldExpand) {
        if (DEBUG) {
            Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
        }
        if (mExpanded == shouldExpand) {
            return false;
        }
        if (mSelectedBubble != null) {
            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
            return;
        }
        if (shouldExpand) {
            if (mBubbles.isEmpty()) {
                Log.e(TAG, "Attempt to expand stack when empty!");
                return false;
                return;
            }
            if (mSelectedBubble == null) {
                Log.e(TAG, "Attempt to expand stack without selected bubble!");
                return false;
                return;
            }
            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
            mOrderChanged |= repackAll();
        } else if (!mBubbles.isEmpty()) {
            // Apply ordering and grouping rules from expanded -> collapsed, then save
            // the result.
            mOrderChanged |= repackAll();
            // Save the state which should be returned to when expanded (with no other changes)

            if (mBubbles.indexOf(mSelectedBubble) > 0) {
                // Move the selected bubble to the top while collapsed.
                if (!mSelectedBubble.isOngoing() && mBubbles.get(0).isOngoing()) {
                    // The selected bubble cannot be raised to the first position because
                    // there is an ongoing bubble there. Instead, force the top ongoing bubble
                    // to become selected.
                    setSelectedBubbleInternal(mBubbles.get(0));
                } else {
            repackAll();
                    // Raise the selected bubble (and it's group) up to the front so the selected
                    // bubble remains on top.
                    mBubbles.remove(mSelectedBubble);
                    mBubbles.add(0, mSelectedBubble);
                    packGroup(0);
                }
            }
        }
        mExpanded = shouldExpand;
        dispatchOnExpandedChanged(mExpanded);
        return true;
        mExpandedChanged = true;
    }

    private static long sortKey(Bubble bubble) {
        long key = bubble.getLastActivity();
        long key = bubble.getLastUpdateTime();
        if (bubble.isOngoing()) {
            // Set 2nd highest bit (signed long int), to partition between ongoing and regular
            key |= 0x4000000000000000L;
@@ -456,8 +503,9 @@ public class BubbleData {
     * unchanged. Relative order of any other bubbles are also unchanged.
     *
     * @param position the position of the first bubble for the group
     * @return true if the position of any bubbles has changed as a result
     */
    private void packGroup(int position) {
    private boolean packGroup(int position) {
        if (DEBUG) {
            Log.d(TAG, "packGroup: position=" + position);
        }
@@ -471,16 +519,27 @@ public class BubbleData {
                moving.add(0, mBubbles.get(i));
            }
        }
        if (moving.isEmpty()) {
            return false;
        }
        mBubbles.removeAll(moving);
        mBubbles.addAll(position + 1, moving);
        return true;
    }

    private void repackAll() {
    /**
     * This applies a full sort and group pass to all existing bubbles. The bubbles are grouped
     * by groupId. Each group is then sorted by the max(lastUpdated) time of it's bubbles. Bubbles
     * within each group are then sorted by lastUpdated descending.
     *
     * @return true if the position of any bubbles changed as a result
     */
    private boolean repackAll() {
        if (DEBUG) {
            Log.d(TAG, "repackAll()");
        }
        if (mBubbles.isEmpty()) {
            return;
            return false;
        }
        Map<String, Long> groupLastActivity = new HashMap<>();
        for (Bubble bubble : mBubbles) {
@@ -494,7 +553,7 @@ public class BubbleData {
        // Sort groups by their most recently active bubble
        List<String> groupsByMostRecentActivity =
                groupLastActivity.entrySet().stream()
                        .sorted(GROUPS_BY_LAST_ACTIVITY_DESCENDING)
                        .sorted(GROUPS_BY_MAX_SORT_KEY_DESCENDING)
                        .map(Map.Entry::getKey)
                        .collect(toList());

@@ -504,10 +563,14 @@ public class BubbleData {
        for (String appId : groupsByMostRecentActivity) {
            mBubbles.stream()
                    .filter((b) -> b.getGroupId().equals(appId))
                    .sorted(BUBBLES_BY_LAST_ACTIVITY_DESCENDING)
                    .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
                    .forEachOrdered(repacked::add);
        }
        if (repacked.equals(mBubbles)) {
            return false;
        }
        mBubbles = repacked;
        return true;
    }

    private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
@@ -527,21 +590,25 @@ public class BubbleData {
    }

    private void onBubbleBlocked(NotificationEntry entry) {
        boolean changed = false;
        final String blockedPackage = entry.notification.getPackageName();
        final String blockedGroupId = Bubble.groupId(entry);
        int selectedIndex = mBubbles.indexOf(mSelectedBubble);
        for (Iterator<Bubble> i = mBubbles.iterator(); i.hasNext(); ) {
            Bubble bubble = i.next();
            if (bubble.getPackageName().equals(blockedPackage)) {
            if (bubble.getGroupId().equals(blockedGroupId)) {
                mRemovedBubbles.add(Pair.create(bubble, BubbleController.DISMISS_BLOCKED));
                i.remove();
                // TODO: handle removal of selected bubble, and collapse safely if emptied (see
                //  dismissAll)
                dispatchOnBubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
                changed = true;
            }
        }
        if (changed) {
            dispatchApply();
        if (mBubbles.isEmpty()) {
            setExpandedInternal(false);
            setSelectedBubbleInternal(null);
        } else if (!mBubbles.contains(mSelectedBubble)) {
            // choose a new one
            int newIndex = Math.min(selectedIndex, mBubbles.size() - 1);
            Bubble newSelected = mBubbles.get(newIndex);
            setSelectedBubbleInternal(newSelected);
        }
        dispatchPendingChanges();
    }

    private int indexForKey(String key) {
+10 −0
Original line number Diff line number Diff line
@@ -549,6 +549,7 @@ public class BubbleStackView extends FrameLayout {
        mBubbleContainer.addView(bubble.iconView, 0,
                new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
        ViewClippingUtil.setClippingDeactivated(bubble.iconView, true, mClippingParameters);
        animateInFlyoutForBubble(bubble);
        requestUpdate();
        logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__POSTED);
    }
@@ -570,10 +571,19 @@ public class BubbleStackView extends FrameLayout {

    // via BubbleData.Listener
    void updateBubble(Bubble bubble) {
        animateInFlyoutForBubble(bubble);
        requestUpdate();
        logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__UPDATED);
    }

    public void updateBubbleOrder(List<Bubble> bubbles) {
        for (int i = 0; i < bubbles.size(); i++) {
            Bubble bubble = bubbles.get(i);
            mBubbleContainer.moveViewTo(bubble.iconView, i);
        }
    }


    /**
     * Changes the currently selected bubble. If the stack is already expanded, the newly selected
     * bubble will be shown immediately. This does not change the expanded state or change the
+19 −19

File changed.

Preview size limit exceeded, changes collapsed.

Loading