Merge "Updates BubbleData sorting and grouping to spec" into qt-dev am: d6cddfedf9

am: 5a3826bead

Change-Id: If58709393408c875253364860582316fdf133ea3
This commit is contained in:
Mark Renouf
2019-05-08 10:45:38 -07:00
committed by android-build-merger
6 changed files with 899 additions and 705 deletions

View File

@@ -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).
*/

View File

@@ -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();

View File

@@ -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());
}
insertBubble(minInsertPoint, bubble);
if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
mOrderChanged = true;
}
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,43 +244,48 @@ 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) {
Bubble bubbleToRemove = mBubbles.get(indexToRemove);
if (mBubbles.size() == 1) {
// Going to become empty, handle specially.
setExpandedInternal(false);
setSelectedBubbleInternal(null);
}
mBubbles.remove(indexToRemove);
dispatchOnBubbleRemoved(bubbleToRemove, reason);
// Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
// Move selection to the new bubble at the same position.
int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
Bubble newSelected = mBubbles.get(newIndex);
setSelectedBubbleInternal(newSelected);
}
bubbleToRemove.setDismissed();
maybeSendDeleteIntent(reason, bubbleToRemove.entry);
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);
mRemovedBubbles.add(Pair.create(bubbleToRemove, reason));
if (!isExpanded()) {
mOrderChanged |= repackAll();
}
// Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
// Move selection to the new bubble at the same position.
int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
Bubble newSelected = mBubbles.get(newIndex);
setSelectedBubbleInternal(newSelected);
}
bubbleToRemove.setDismissed();
maybeSendDeleteIntent(reason, bubbleToRemove.entry);
}
public void dismissAll(@DismissReason int reason) {
@@ -281,87 +301,98 @@ 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) {
private void dispatchPendingChanges() {
if (mListener == null) {
mExpandedChanged = false;
mAddedBubble = null;
mSelectionChanged = false;
mRemovedBubbles.clear();
mUpdatedBubble = null;
mOrderChanged = false;
return;
}
boolean anythingChanged = false;
if (mAddedBubble != null) {
mListener.onBubbleAdded(mAddedBubble);
mAddedBubble = null;
anythingChanged = true;
}
// Compat workaround: Always collapse first.
if (mExpandedChanged && !mExpanded) {
mListener.onExpandedChanged(mExpanded);
mExpandedChanged = false;
anythingChanged = true;
}
if (mSelectionChanged) {
mListener.onSelectionChanged(mSelectedBubble);
mSelectionChanged = false;
anythingChanged = true;
}
if (!mRemovedBubbles.isEmpty()) {
for (Pair<Bubble, Integer> removed : mRemovedBubbles) {
mListener.onBubbleRemoved(removed.first, removed.second);
}
mRemovedBubbles.clear();
anythingChanged = true;
}
if (mUpdatedBubble != null) {
mListener.onBubbleUpdated(mUpdatedBubble);
mUpdatedBubble = null;
anythingChanged = true;
}
if (mOrderChanged) {
mListener.onOrderChanged(mBubbles);
mOrderChanged = false;
anythingChanged = true;
}
if (mExpandedChanged) {
mListener.onExpandedChanged(mExpanded);
mExpandedChanged = false;
anythingChanged = true;
}
if (anythingChanged) {
mListener.apply();
}
}
private void dispatchOnBubbleAdded(Bubble bubble) {
if (mListener != null) {
mListener.onBubbleAdded(bubble);
}
}
private void dispatchOnBubbleRemoved(Bubble bubble, @DismissReason int reason) {
if (mListener != null) {
mListener.onBubbleRemoved(bubble, reason);
}
}
private void dispatchOnExpandedChanged(boolean expanded) {
if (mListener != null) {
mListener.onExpandedChanged(expanded);
}
}
private void dispatchOnSelectionChanged(@Nullable Bubble bubble) {
if (mListener != null) {
mListener.onSelectionChanged(bubble);
}
}
private void dispatchOnBubbleUpdated(Bubble bubble) {
if (mListener != null) {
mListener.onBubbleUpdated(bubble);
}
}
private void dispatchOnOrderChanged(List<Bubble> bubbles) {
if (mListener != null) {
mListener.onOrderChanged(bubbles);
}
}
private void dispatchShowFlyoutText(Bubble bubble, String text) {
if (mListener != null) {
mListener.showFlyoutText(bubble, text);
}
}
/**
* Requests a change to the selected bubble. Calls {@link Listener#onSelectionChanged} if
* 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 {
// 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);
}
}
} else {
repackAll();
}
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) {

View File

@@ -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

View File

@@ -296,22 +296,22 @@ public class BubbleControllerTest extends SysuiTestCase {
BubbleStackView stackView = mBubbleController.getStackView();
mBubbleController.expandStack();
assertTrue(mBubbleController.isStackExpanded());
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
// First added is the one that is expanded
assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
assertFalse(mRow.getEntry().showInShadeWhenBubble());
// Switch which bubble is expanded
mBubbleController.selectBubble(mRow2.getEntry().key);
stackView.setExpandedBubble(mRow2.getEntry());
// Last added is the one that is expanded
assertEquals(mRow2.getEntry(), stackView.getExpandedBubbleView().getEntry());
assertFalse(mRow2.getEntry().showInShadeWhenBubble());
// Switch which bubble is expanded
mBubbleController.selectBubble(mRow.getEntry().key);
stackView.setExpandedBubble(mRow.getEntry());
assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
assertFalse(mRow.getEntry().showInShadeWhenBubble());
// collapse for previous bubble
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
// expand for selected bubble
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
// Collapse
mBubbleController.collapseStack();
@@ -352,27 +352,27 @@ public class BubbleControllerTest extends SysuiTestCase {
mBubbleController.expandStack();
assertTrue(mBubbleController.isStackExpanded());
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
// First added is the one that is expanded
assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
assertFalse(mRow.getEntry().showInShadeWhenBubble());
// Last added is the one that is expanded
assertEquals(mRow2.getEntry(), stackView.getExpandedBubbleView().getEntry());
assertFalse(mRow2.getEntry().showInShadeWhenBubble());
// Dismiss currently expanded
mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
BubbleController.DISMISS_USER_GESTURE);
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
// Make sure next bubble is selected
assertEquals(mRow2.getEntry(), stackView.getExpandedBubbleView().getEntry());
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
// Make sure first bubble is selected
assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
// Dismiss that one
mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
BubbleController.DISMISS_USER_GESTURE);
// Make sure state changes and collapse happens
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
assertFalse(mBubbleController.hasBubbles());
}