diff options
| author | 2020-02-07 16:18:22 +0000 | |
|---|---|---|
| committer | 2020-02-07 16:18:22 +0000 | |
| commit | 2690ffbbe678a587c30ba1f62e5626caa88ca7a2 (patch) | |
| tree | b3c2a47fae2eeaf20595216c9a0ed044a50074ce | |
| parent | c34c238af65920be00e25c9c1669e6fc56a992f0 (diff) | |
| parent | a53fb0db1638cc67f2ae816dd445aed453db5d5d (diff) | |
Merge "Add BubbleCoordinator"
16 files changed, 1738 insertions, 170 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java index 792b9403dffd..05838abe184a 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java @@ -16,7 +16,6 @@ package com.android.systemui.bubbles; -import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY; import static android.app.Notification.FLAG_BUBBLE; import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL; import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL_ALL; @@ -69,18 +68,28 @@ import androidx.annotation.Nullable; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.statusbar.IStatusBarService; +import com.android.internal.statusbar.NotificationVisibility; +import com.android.systemui.DumpController; +import com.android.systemui.Dumpable; import com.android.systemui.R; +import com.android.systemui.bubbles.BubbleController.BubbleExpandListener; +import com.android.systemui.bubbles.BubbleController.BubbleStateChangeListener; +import com.android.systemui.bubbles.BubbleController.NotifCallback; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.shared.system.ActivityManagerWrapper; import com.android.systemui.shared.system.PinnedStackListenerForwarder; import com.android.systemui.shared.system.TaskStackChangeListener; import com.android.systemui.shared.system.WindowManagerWrapper; +import com.android.systemui.statusbar.FeatureFlags; import com.android.systemui.statusbar.NotificationLockscreenUserManager; import com.android.systemui.statusbar.NotificationRemoveInterceptor; import com.android.systemui.statusbar.notification.NotificationEntryListener; import com.android.systemui.statusbar.notification.NotificationEntryManager; import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider; +import com.android.systemui.statusbar.notification.collection.NotifCollection; +import com.android.systemui.statusbar.notification.collection.NotifPipeline; import com.android.systemui.statusbar.notification.collection.NotificationEntry; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener; import com.android.systemui.statusbar.phone.NotificationGroupManager; import com.android.systemui.statusbar.phone.NotificationShadeWindowController; import com.android.systemui.statusbar.phone.ShadeController; @@ -106,7 +115,7 @@ import javax.inject.Singleton; * The controller manages addition, removal, and visible state of bubbles on screen. */ @Singleton -public class BubbleController implements ConfigurationController.ConfigurationListener { +public class BubbleController implements ConfigurationController.ConfigurationListener, Dumpable { private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleController" : TAG_BUBBLES; @@ -130,6 +139,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi private final Context mContext; private final NotificationEntryManager mNotificationEntryManager; + private final NotifPipeline mNotifPipeline; private final BubbleTaskStackListener mTaskStackListener; private BubbleStateChangeListener mStateChangeListener; private BubbleExpandListener mExpandListener; @@ -220,16 +230,17 @@ public class BubbleController implements ConfigurationController.ConfigurationLi */ public interface NotifCallback { /** - * Called when the BubbleController wants to remove an entry that it was previously hiding - * from the shade. See {@link BubbleController#isBubbleNotificationSuppressedFromShade}. + * Called when a bubbled notification that was hidden from the shade is now being removed + * This can happen when an app cancels a bubbled notification or when the user dismisses a + * bubble. */ - void removeNotification(NotificationEntry entry); + void removeNotification(NotificationEntry entry, int reason); /** * Called when a bubbled notification has changed whether it should be * filtered from the shade. */ - void invalidateNotificationFilter(String reason); + void invalidateNotifications(String reason); /** * Called on a bubbled entry that has been removed when there are no longer @@ -277,10 +288,14 @@ public class BubbleController implements ConfigurationController.ConfigurationLi ZenModeController zenModeController, NotificationLockscreenUserManager notifUserManager, NotificationGroupManager groupManager, - NotificationEntryManager entryManager) { + NotificationEntryManager entryManager, + NotifPipeline notifPipeline, + FeatureFlags featureFlags, + DumpController dumpController) { this(context, notificationShadeWindowController, statusBarStateController, shadeController, data, null /* synchronizer */, configurationController, interruptionStateProvider, - zenModeController, notifUserManager, groupManager, entryManager); + zenModeController, notifUserManager, groupManager, entryManager, + notifPipeline, featureFlags, dumpController); } public BubbleController(Context context, @@ -294,7 +309,11 @@ public class BubbleController implements ConfigurationController.ConfigurationLi ZenModeController zenModeController, NotificationLockscreenUserManager notifUserManager, NotificationGroupManager groupManager, - NotificationEntryManager entryManager) { + NotificationEntryManager entryManager, + NotifPipeline notifPipeline, + FeatureFlags featureFlags, + DumpController dumpController) { + dumpController.registerDumpable(TAG, this); mContext = context; mShadeController = shadeController; mNotificationInterruptionStateProvider = interruptionStateProvider; @@ -337,7 +356,13 @@ public class BubbleController implements ConfigurationController.ConfigurationLi mNotificationEntryManager = entryManager; mNotificationGroupManager = groupManager; - setupNEM(); + mNotifPipeline = notifPipeline; + + if (!featureFlags.isNewNotifPipelineRenderingEnabled()) { + setupNEM(); + } else { + setupNotifPipeline(); + } mNotificationShadeWindowController = notificationShadeWindowController; mStatusBarStateListener = new StatusBarStateListener(); @@ -396,6 +421,14 @@ public class BubbleController implements ConfigurationController.ConfigurationLi } @Override + public void onEntryRemoved( + NotificationEntry entry, + @android.annotation.Nullable NotificationVisibility visibility, + boolean removedByUser) { + BubbleController.this.onEntryRemoved(entry); + } + + @Override public void onNotificationRankingUpdated(RankingMap rankingMap) { onRankingUpdated(rankingMap); } @@ -405,8 +438,29 @@ public class BubbleController implements ConfigurationController.ConfigurationLi new NotificationRemoveInterceptor() { @Override public boolean onNotificationRemoveRequested( - String key, NotificationEntry entry, int reason) { - return shouldInterceptDismissal(entry, reason); + String key, + NotificationEntry entry, + int dismissReason) { + final boolean isClearAll = dismissReason == REASON_CANCEL_ALL; + final boolean isUserDimiss = dismissReason == REASON_CANCEL + || dismissReason == REASON_CLICK; + final boolean isAppCancel = dismissReason == REASON_APP_CANCEL + || dismissReason == REASON_APP_CANCEL_ALL; + final boolean isSummaryCancel = + dismissReason == REASON_GROUP_SUMMARY_CANCELED; + + // Need to check for !appCancel here because the notification may have + // previously been dismissed & entry.isRowDismissed would still be true + boolean userRemovedNotif = + (entry != null && entry.isRowDismissed() && !isAppCancel) + || isClearAll || isUserDimiss || isSummaryCancel; + + if (userRemovedNotif || isUserCreatedBubble(key) + || isSummaryOfUserCreatedBubble(entry)) { + return handleDismissalInterception(entry); + } + + return false; } }); @@ -430,13 +484,13 @@ public class BubbleController implements ConfigurationController.ConfigurationLi addNotifCallback(new NotifCallback() { @Override - public void removeNotification(NotificationEntry entry) { + public void removeNotification(NotificationEntry entry, int reason) { mNotificationEntryManager.performRemoveNotification(entry.getSbn(), - UNDEFINED_DISMISS_REASON); + reason); } @Override - public void invalidateNotificationFilter(String reason) { + public void invalidateNotifications(String reason) { mNotificationEntryManager.updateNotifications(reason); } @@ -444,18 +498,28 @@ public class BubbleController implements ConfigurationController.ConfigurationLi public void maybeCancelSummary(NotificationEntry entry) { // Check if removed bubble has an associated suppressed group summary that needs // to be removed now. - final String groupKey = entry.getSbn().getGroup(); + final String groupKey = entry.getSbn().getGroupKey(); if (mBubbleData.isSummarySuppressed(groupKey)) { - mBubbleData.removeSuppressedSummary(entry.getSbn().getGroupKey()); + mBubbleData.removeSuppressedSummary(groupKey); final NotificationEntry summary = mNotificationEntryManager.getActiveNotificationUnfiltered( mBubbleData.getSummaryKey(groupKey)); - mNotificationEntryManager.performRemoveNotification(summary.getSbn(), - UNDEFINED_DISMISS_REASON); + if (summary != null) { + mNotificationEntryManager.performRemoveNotification(summary.getSbn(), + UNDEFINED_DISMISS_REASON); + } } - // Check if summary should be removed from NoManGroup + // Check if we still need to remove the summary from NoManGroup because the summary + // may not be in the mBubbleData.mSuppressedGroupKeys list and removed above. + // For example: + // 1. Bubbled notifications (group) is posted to shade and are visible bubbles + // 2. User expands bubbles so now their respective notifications in the shade are + // hidden, including the group summary + // 3. User removes all bubbles + // 4. We expect all the removed bubbles AND the summary (note: the summary was + // never added to the suppressedSummary list in BubbleData, so we add this check) NotificationEntry summary = mNotificationGroupManager.getLogicalGroupSummary(entry.getSbn()); if (summary != null) { @@ -472,6 +536,31 @@ public class BubbleController implements ConfigurationController.ConfigurationLi }); } + private void setupNotifPipeline() { + mNotifPipeline.addCollectionListener(new NotifCollectionListener() { + @Override + public void onEntryAdded(NotificationEntry entry) { + BubbleController.this.onEntryAdded(entry); + } + + @Override + public void onEntryUpdated(NotificationEntry entry) { + BubbleController.this.onEntryUpdated(entry); + } + + @Override + public void onRankingUpdate(RankingMap rankingMap) { + onRankingUpdated(rankingMap); + } + + @Override + public void onEntryRemoved(NotificationEntry entry, + @NotifCollection.CancellationReason int reason) { + BubbleController.this.onEntryRemoved(entry); + } + }); + } + /** * Sets whether to perform inflation on the same thread as the caller. This method should only * be used in tests, not in production. @@ -752,7 +841,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi Log.d(TAG, "onUserDemotedBubble: " + entry.getKey()); } entry.setFlagBubble(false); - removeBubble(entry.getKey(), DISMISS_BLOCKED); + removeBubble(entry, DISMISS_BLOCKED); mUserCreatedBubbles.remove(entry.getKey()); if (BubbleExperimentConfig.isPackageWhitelistedToAutoBubble( mContext, entry.getSbn().getPackageName())) { @@ -769,17 +858,29 @@ public class BubbleController implements ConfigurationController.ConfigurationLi return mUserCreatedBubbles.contains(key); } + boolean isSummaryOfUserCreatedBubble(NotificationEntry entry) { + if (isSummaryOfBubbles(entry)) { + List<Bubble> bubbleChildren = + mBubbleData.getBubblesInGroup(entry.getSbn().getGroupKey()); + for (int i = 0; i < bubbleChildren.size(); i++) { + // Check if any are user-created (i.e. experimental bubbles) + if (isUserCreatedBubble(bubbleChildren.get(i).getKey())) { + return true; + } + } + } + return false; + } + /** - * Removes the bubble associated with the {@param uri}. + * Removes the bubble with the given NotificationEntry. * <p> * Must be called from the main thread. */ @MainThread - void removeBubble(String key, int reason) { - // TEMP: refactor to change this to pass entry - Bubble bubble = mBubbleData.getBubbleWithKey(key); - if (bubble != null) { - mBubbleData.notificationEntryRemoved(bubble.getEntry(), reason); + void removeBubble(NotificationEntry entry, int reason) { + if (mBubbleData.hasBubbleWithKey(entry.getKey())) { + mBubbleData.notificationEntryRemoved(entry, reason); } } @@ -809,7 +910,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi && (canLaunchInActivityView(mContext, entry) || wasAdjusted); if (!shouldBubble && mBubbleData.hasBubbleWithKey(entry.getKey())) { // It was previously a bubble but no longer a bubble -- lets remove it - removeBubble(entry.getKey(), DISMISS_NO_LONGER_BUBBLE); + removeBubble(entry, DISMISS_NO_LONGER_BUBBLE); } else if (shouldBubble) { if (wasAdjusted && !previouslyUserCreated) { // Gotta treat the auto-bubbled / whitelisted packaged bubbles as usercreated @@ -819,6 +920,21 @@ public class BubbleController implements ConfigurationController.ConfigurationLi } } + private void onEntryRemoved(NotificationEntry entry) { + if (isSummaryOfBubbles(entry)) { + final String groupKey = entry.getSbn().getGroupKey(); + mBubbleData.removeSuppressedSummary(groupKey); + + // Remove any associated bubble children with the summary + final List<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey); + for (int i = 0; i < bubbleChildren.size(); i++) { + removeBubble(bubbleChildren.get(i).getEntry(), DISMISS_GROUP_CANCELLED); + } + } else { + removeBubble(entry, DISMISS_NOTIF_CANCEL); + } + } + private void onRankingUpdated(RankingMap rankingMap) { // Forward to BubbleData to block any bubbles which should no longer be shown mBubbleData.notificationRankingUpdated(rankingMap); @@ -846,7 +962,6 @@ public class BubbleController implements ConfigurationController.ConfigurationLi final Bubble bubble = removed.first; @DismissReason final int reason = removed.second; mStackView.removeBubble(bubble); - // If the bubble is removed for user switching, leave the notification in place. if (reason != DISMISS_USER_CHANGED) { if (!mBubbleData.hasBubbleWithKey(bubble.getKey()) @@ -854,7 +969,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi // The bubble is now gone & the notification is hidden from the shade, so // time to actually remove it for (NotifCallback cb : mCallbacks) { - cb.removeNotification(bubble.getEntry()); + cb.removeNotification(bubble.getEntry(), REASON_CANCEL); } } else { // Update the flag for SysUI @@ -908,7 +1023,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi } for (NotifCallback cb : mCallbacks) { - cb.invalidateNotificationFilter("BubbleData.Listener.applyUpdate"); + cb.invalidateNotifications("BubbleData.Listener.applyUpdate"); } updateStack(); @@ -930,124 +1045,85 @@ public class BubbleController implements ConfigurationController.ConfigurationLi }; /** - * We intercept notification entries cancelled by the user (i.e. dismissed) when there is an - * active bubble associated with it. We do this so that developers can still cancel it - * (and hence the bubbles associated with it). However, these intercepted notifications - * should then be hidden from the shade since the user has cancelled them, so we update - * {@link Bubble#showInShade}. - * - * The cancellation of summaries with children associated with bubbles are also handled in this - * method. User-cancelled summaries are tracked by {@link BubbleData#addSummaryToSuppress}. + * We intercept notification entries (including group summaries) dismissed by the user when + * there is an active bubble associated with it. We do this so that developers can still + * cancel it (and hence the bubbles associated with it). However, these intercepted + * notifications should then be hidden from the shade since the user has cancelled them, so we + * {@link Bubble#setSuppressNotification}. For the case of suppressed summaries, we also add + * {@link BubbleData#addSummaryToSuppress}. * * @return true if we want to intercept the dismissal of the entry, else false. */ - public boolean shouldInterceptDismissal(NotificationEntry entry, int dismissReason) { + public boolean handleDismissalInterception(NotificationEntry entry) { if (entry == null) { return false; } - String key = entry.getKey(); - String groupKey = entry != null ? entry.getSbn().getGroupKey() : null; - ArrayList<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey); - boolean inBubbleData = mBubbleData.hasBubbleWithKey(key); - boolean isSuppressedSummary = (mBubbleData.isSummarySuppressed(groupKey) - && mBubbleData.getSummaryKey(groupKey).equals(key)); - boolean isSummary = entry != null - && entry.getSbn().getNotification().isGroupSummary(); - boolean isSummaryOfBubbles = (isSuppressedSummary || isSummary) - && bubbleChildren != null && !bubbleChildren.isEmpty(); + final boolean interceptBubbleDismissal = mBubbleData.hasBubbleWithKey(entry.getKey()) + && entry.isBubble(); + final boolean interceptSummaryDismissal = isSummaryOfBubbles(entry); - if (!inBubbleData && !isSummaryOfBubbles) { + if (interceptSummaryDismissal) { + handleSummaryDismissalInterception(entry); + } else if (interceptBubbleDismissal) { + Bubble bubble = mBubbleData.getBubbleWithKey(entry.getKey()); + bubble.setSuppressNotification(true); + bubble.setShowDot(false /* show */, true /* animate */); + } else { return false; } - final boolean isClearAll = dismissReason == REASON_CANCEL_ALL; - final boolean isUserDimiss = dismissReason == REASON_CANCEL - || dismissReason == REASON_CLICK; - final boolean isAppCancel = dismissReason == REASON_APP_CANCEL - || dismissReason == REASON_APP_CANCEL_ALL; - final boolean isSummaryCancel = dismissReason == REASON_GROUP_SUMMARY_CANCELED; - - // Need to check for !appCancel here because the notification may have - // previously been dismissed & entry.isRowDismissed would still be true - boolean userRemovedNotif = (entry != null && entry.isRowDismissed() && !isAppCancel) - || isClearAll || isUserDimiss || isSummaryCancel; - if (isSummaryOfBubbles) { - return handleSummaryRemovalInterception(entry, userRemovedNotif); + // Update the shade + for (NotifCallback cb : mCallbacks) { + cb.invalidateNotifications("BubbleController.handleDismissalInterception"); } + return true; + } - // The bubble notification sticks around in the data as long as the bubble is - // not dismissed and the app hasn't cancelled the notification. - Bubble bubble = mBubbleData.getBubbleWithKey(key); - boolean bubbleExtended = entry != null && entry.isBubble() - && (userRemovedNotif || isUserCreatedBubble(bubble.getKey())); - if (bubbleExtended) { - bubble.setSuppressNotification(true); - bubble.setShowDot(false /* show */, true /* animate */); - for (NotifCallback cb : mCallbacks) { - cb.invalidateNotificationFilter("BubbleController" - + ".shouldInterceptDismissal"); - } - return true; - } else if (!userRemovedNotif && entry != null) { - // This wasn't a user removal so we should remove the bubble as well - mBubbleData.notificationEntryRemoved(entry, DISMISS_NOTIF_CANCEL); + private boolean isSummaryOfBubbles(NotificationEntry entry) { + if (entry == null) { return false; } - return false; - } - private boolean handleSummaryRemovalInterception(NotificationEntry summary, - boolean userRemovedNotif) { - String groupKey = summary.getSbn().getGroupKey(); + String groupKey = entry.getSbn().getGroupKey(); ArrayList<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey); + boolean isSuppressedSummary = (mBubbleData.isSummarySuppressed(groupKey) + && mBubbleData.getSummaryKey(groupKey).equals(entry.getKey())); + boolean isSummary = entry.getSbn().getNotification().isGroupSummary(); + return (isSuppressedSummary || isSummary) + && bubbleChildren != null + && !bubbleChildren.isEmpty(); + } - if (userRemovedNotif) { - // If it's a user dismiss we mark the children to be hidden from the shade. - for (int i = 0; i < bubbleChildren.size(); i++) { - Bubble bubbleChild = bubbleChildren.get(i); - // As far as group manager is concerned, once a child is no longer shown - // in the shade, it is essentially removed. - mNotificationGroupManager.onEntryRemoved(bubbleChild.getEntry()); - bubbleChild.setSuppressNotification(true); - bubbleChild.setShowDot(false /* show */, true /* animate */); - } - // And since all children are removed, remove the summary. - mNotificationGroupManager.onEntryRemoved(summary); - - // If the summary was auto-generated we don't need to keep that notification around - // because apps can't cancel it; so we only intercept & suppress real summaries. - boolean isAutogroupSummary = (summary.getSbn().getNotification().flags - & FLAG_AUTOGROUP_SUMMARY) != 0; - if (!isAutogroupSummary) { - // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated - mBubbleData.addSummaryToSuppress(summary.getSbn().getGroupKey(), - summary.getKey()); - // Tell shade to update for the suppression - mNotificationEntryManager.updateNotifications("BubbleController" - + ".handleSummaryRemovalInterception"); - } - return !isAutogroupSummary; - } else { - // If it's not a user dismiss it's a cancel. - for (int i = 0; i < bubbleChildren.size(); i++) { - // First check if any of these are user-created (i.e. experimental bubbles) - if (mUserCreatedBubbles.contains(bubbleChildren.get(i).getKey())) { - // Experimental bubble! Intercept the removal. - return true; + private void handleSummaryDismissalInterception(NotificationEntry summary) { + // current children in the row: + final List<NotificationEntry> children = summary.getChildren(); + if (children != null) { + for (int i = 0; i < children.size(); i++) { + NotificationEntry child = children.get(i); + if (mBubbleData.hasBubbleWithKey(child.getKey())) { + // Suppress the bubbled child + // As far as group manager is concerned, once a child is no longer shown + // in the shade, it is essentially removed. + Bubble bubbleChild = mBubbleData.getBubbleWithKey(child.getKey()); + mNotificationGroupManager.onEntryRemoved(bubbleChild.getEntry()); + bubbleChild.setSuppressNotification(true); + bubbleChild.setShowDot(false /* show */, true /* animate */); + } else { + // non-bubbled children can be removed + for (NotifCallback cb : mCallbacks) { + cb.removeNotification(child, REASON_GROUP_SUMMARY_CANCELED); + } } } - - // Not an experimental bubble, safe to remove. - mBubbleData.removeSuppressedSummary(groupKey); - // Remove any associated bubble children with the summary. - for (int i = 0; i < bubbleChildren.size(); i++) { - Bubble bubbleChild = bubbleChildren.get(i); - mBubbleData.notificationEntryRemoved(bubbleChild.getEntry(), - DISMISS_GROUP_CANCELLED); - } - return false; } + + // And since all children are removed, remove the summary. + mNotificationGroupManager.onEntryRemoved(summary); + + // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated + mBubbleData.addSummaryToSuppress(summary.getSbn().getGroupKey(), + summary.getKey()); } /** diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java index 50a50633f43c..0d5261dcb7f3 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java @@ -19,9 +19,9 @@ package com.android.systemui.bubbles; import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK; import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT; import static android.view.Display.INVALID_DISPLAY; - import static android.view.ViewRootImpl.NEW_INSETS_MODE_FULL; import static android.view.ViewRootImpl.sNewInsetsMode; + import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_EXPANDED_VIEW; import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES; import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME; @@ -56,6 +56,7 @@ import com.android.systemui.R; import com.android.systemui.recents.TriangleShape; import com.android.systemui.shared.system.SysUiStatsLog; import com.android.systemui.statusbar.AlphaOptimizedButton; +import com.android.systemui.statusbar.notification.collection.NotificationEntry; /** * Container for the expanded bubble view, handles rendering the caret and settings icon. @@ -146,7 +147,7 @@ public class BubbleExpandedView extends LinearLayout implements View.OnClickList // the bubble again so we'll just remove it. Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey() + ", " + e.getMessage() + "; removing bubble"); - mBubbleController.removeBubble(getBubbleKey(), + mBubbleController.removeBubble(getBubbleEntry(), BubbleController.DISMISS_INVALID_INTENT); } }); @@ -190,7 +191,7 @@ public class BubbleExpandedView extends LinearLayout implements View.OnClickList } if (mBubble != null && !mBubbleController.isUserCreatedBubble(mBubble.getKey())) { // Must post because this is called from a binder thread. - post(() -> mBubbleController.removeBubble(mBubble.getKey(), + post(() -> mBubbleController.removeBubble(mBubble.getEntry(), BubbleController.DISMISS_TASK_FINISHED)); } } @@ -279,6 +280,10 @@ public class BubbleExpandedView extends LinearLayout implements View.OnClickList return mBubble != null ? mBubble.getKey() : "null"; } + private NotificationEntry getBubbleEntry() { + return mBubble != null ? mBubble.getEntry() : null; + } + void applyThemeAttrs() { final TypedArray ta = mContext.obtainStyledAttributes( new int[] { diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java index 34d3c24b4f48..645696d0bcac 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java @@ -198,9 +198,14 @@ class BubbleTouchHandler implements View.OnTouchListener { if (isStack) { mController.dismissStack(BubbleController.DISMISS_USER_GESTURE); } else { - mController.removeBubble( - individualBubbleKey, - BubbleController.DISMISS_USER_GESTURE); + final Bubble bubble = + mBubbleData.getBubbleWithKey(individualBubbleKey); + // bubble can be null if the user is in the middle of + // dismissing the bubble, but the app also sent a cancel + if (bubble != null) { + mController.removeBubble(bubble.getEntry(), + BubbleController.DISMISS_USER_GESTURE); + } } }); } else if (isFlyout) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java index 7fe229c26f3a..3fa1954a7fcc 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java @@ -123,6 +123,16 @@ public class ListDumper { .append(" "); } + if (!notifEntry.mDismissInterceptors.isEmpty()) { + String[] interceptorsNames = new String[notifEntry.mDismissInterceptors.size()]; + for (int i = 0; i < interceptorsNames.length; i++) { + interceptorsNames[i] = notifEntry.mDismissInterceptors.get(i).getName(); + } + rksb.append("dismissInterceptors=") + .append(Arrays.toString(interceptorsNames)) + .append(" "); + } + if (notifEntry.mExcludingFilter != null) { rksb.append("filter=") .append(notifEntry.mExcludingFilter) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java index 3b2fe9441c32..38d8d979a4da 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java @@ -63,6 +63,7 @@ import com.android.systemui.statusbar.notification.collection.notifcollection.Co import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionLogger; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender; import com.android.systemui.util.Assert; @@ -116,6 +117,7 @@ public class NotifCollection implements Dumpable { @Nullable private CollectionReadyForBuildListener mBuildListener; private final List<NotifCollectionListener> mNotifCollectionListeners = new ArrayList<>(); private final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>(); + private final List<NotifDismissInterceptor> mDismissInterceptors = new ArrayList<>(); private boolean mAttached = false; private boolean mAmDispatchingToOtherCode; @@ -176,10 +178,21 @@ public class NotifCollection implements Dumpable { extender.setCallback(this::onEndLifetimeExtension); } + /** @see NotifPipeline#addNotificationDismissInterceptor(NotifDismissInterceptor) */ + void addNotificationDismissInterceptor(NotifDismissInterceptor interceptor) { + Assert.isMainThread(); + checkForReentrantCall(); + if (mDismissInterceptors.contains(interceptor)) { + throw new IllegalArgumentException("Interceptor " + interceptor + " already added."); + } + mDismissInterceptors.add(interceptor); + interceptor.setCallback(this::onEndDismissInterception); + } + /** * Dismiss a notification on behalf of the user. */ - void dismissNotification(NotificationEntry entry, @NonNull DismissedByUserStats stats) { + public void dismissNotification(NotificationEntry entry, @NonNull DismissedByUserStats stats) { Assert.isMainThread(); requireNonNull(stats); checkForReentrantCall(); @@ -192,6 +205,12 @@ public class NotifCollection implements Dumpable { return; } + updateDismissInterceptors(entry); + if (isDismissIntercepted(entry)) { + mLogger.logNotifDismissedIntercepted(entry.getKey()); + return; + } + // Optimistically mark the notification as dismissed -- we'll wait for the signal from // system server before removing it from our notification set. entry.setDismissState(DISMISSED); @@ -236,7 +255,6 @@ public class NotifCollection implements Dumpable { for (NotificationEntry canceledEntry : canceledEntries) { tryRemoveNotification(canceledEntry); } - rebuildList(); } @@ -307,11 +325,11 @@ public class NotifCollection implements Dumpable { // Update to an existing entry mLogger.logNotifUpdated(sbn.getKey()); + // Notification is updated so it is essentially re-added and thus alive again, so we + // can reset its state. cancelLocalDismissal(entry); - - // Notification is updated so it is essentially re-added and thus alive again. Don't - // need to keep its lifetime extended. cancelLifetimeExtension(entry); + cancelDismissInterception(entry); entry.mCancellationReason = REASON_NOT_CANCELED; entry.setSbn(sbn); @@ -348,6 +366,7 @@ public class NotifCollection implements Dumpable { if (!isLifetimeExtended(entry)) { mNotificationSet.remove(entry.getKey()); + cancelDismissInterception(entry); dispatchOnEntryRemoved(entry, entry.mCancellationReason); dispatchOnEntryCleanUp(entry); return true; @@ -436,6 +455,17 @@ public class NotifCollection implements Dumpable { mAmDispatchingToOtherCode = false; } + private void updateDismissInterceptors(@NonNull NotificationEntry entry) { + entry.mDismissInterceptors.clear(); + mAmDispatchingToOtherCode = true; + for (NotifDismissInterceptor interceptor : mDismissInterceptors) { + if (interceptor.shouldInterceptDismissal(entry)) { + entry.mDismissInterceptors.add(interceptor); + } + } + mAmDispatchingToOtherCode = false; + } + private void cancelLocalDismissal(NotificationEntry entry) { if (isDismissedByUser(entry)) { entry.setDismissState(NOT_DISMISSED); @@ -450,6 +480,42 @@ public class NotifCollection implements Dumpable { } } + private void onEndDismissInterception( + NotifDismissInterceptor interceptor, + NotificationEntry entry, + @NonNull DismissedByUserStats stats) { + Assert.isMainThread(); + if (!mAttached) { + return; + } + checkForReentrantCall(); + + if (!entry.mDismissInterceptors.remove(interceptor)) { + throw new IllegalStateException( + String.format( + "Cannot end dismiss interceptor for interceptor \"%s\" (%s)", + interceptor.getName(), + interceptor)); + } + + if (!isDismissIntercepted(entry)) { + dismissNotification(entry, stats); + } + } + + private void cancelDismissInterception(NotificationEntry entry) { + mAmDispatchingToOtherCode = true; + for (NotifDismissInterceptor interceptor : entry.mDismissInterceptors) { + interceptor.cancelDismissInterception(entry); + } + mAmDispatchingToOtherCode = false; + entry.mDismissInterceptors.clear(); + } + + private boolean isDismissIntercepted(NotificationEntry entry) { + return entry.mDismissInterceptors.size() > 0; + } + private void checkForReentrantCall() { if (mAmDispatchingToOtherCode) { throw new IllegalStateException("Reentrant call detected"); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java index 5767ad93014e..d4d2369ba822 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java @@ -25,6 +25,7 @@ import com.android.systemui.statusbar.notification.collection.listbuilder.plugga import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSection; import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender; import java.util.Collection; @@ -97,13 +98,21 @@ public class NotifPipeline implements CommonNotifCollection { /** * Registers a lifetime extender. Lifetime extenders can cause notifications that have been - * dismissed or retracted to be temporarily retained in the collection. + * dismissed or retracted by system server to be temporarily retained in the collection. */ public void addNotificationLifetimeExtender(NotifLifetimeExtender extender) { mNotifCollection.addNotificationLifetimeExtender(extender); } /** + * Registers a dismiss interceptor. Dismiss interceptors can cause notifications that have been + * dismissed by the user to be retained (won't send a dismissal to system server). + */ + public void addNotificationDismissInterceptor(NotifDismissInterceptor interceptor) { + mNotifCollection.addNotificationDismissInterceptor(interceptor); + } + + /** * Registers a filter with the pipeline before grouping, promoting and sorting occurs. Filters * are called on each notification in the order that they were registered. If any filter * returns true, the notification is removed from the pipeline (and no other filters are diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java index a489f3b22031..006d40ddbac5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java @@ -66,6 +66,7 @@ import com.android.systemui.statusbar.notification.InflationException; import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason; import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter; import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController; @@ -105,6 +106,9 @@ public final class NotificationEntry extends ListEntry { /** List of lifetime extenders that are extending the lifetime of this notification. */ final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>(); + /** List of dismiss interceptors that are intercepting the dismissal of this notification. */ + final List<NotifDismissInterceptor> mDismissInterceptors = new ArrayList<>(); + /** If this notification was filtered out, then the filter that did the filtering. */ @Nullable NotifFilter mExcludingFilter; diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java new file mode 100644 index 000000000000..116c70c4f1cf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java @@ -0,0 +1,164 @@ +/* + * 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.systemui.statusbar.notification.collection.coordinator; + +import static android.service.notification.NotificationStats.DISMISSAL_OTHER; +import static android.service.notification.NotificationStats.DISMISS_SENTIMENT_UNKNOWN; + +import com.android.internal.statusbar.NotificationVisibility; +import com.android.systemui.bubbles.BubbleController; +import com.android.systemui.statusbar.notification.collection.NotifCollection; +import com.android.systemui.statusbar.notification.collection.NotifPipeline; +import com.android.systemui.statusbar.notification.collection.NotificationEntry; +import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter; +import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor; +import com.android.systemui.statusbar.notification.logging.NotificationLogger; + +import java.util.HashSet; +import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Coordinates hiding, intercepting (the dismissal), and deletion of bubbled notifications. + * + * The typical "start state" for a bubbled notification is when a bubble-able notification is + * posted. It is visible as a bubble AND as a notification in the shade. From here, we can get + * into a few hidden-from-shade states described below: + * + * Start State -> Hidden from shade + * User expands the bubble so we hide its notification from the shade. + * OR + * User dismisses a group summary with a bubbled child. All bubbled children are now hidden from + * the shade. And the group summary's dismissal is intercepted + hidden from the shade (see below). + * + * Start State -> Dismissal intercepted + hidden from shade + * User dismisses the notification from the shade. We now hide the notification from the shade + * and intercept its dismissal (the removal signal is never sent to system server). We + * keep the notification alive in system server so that {@link BubbleController} can still + * respond to app-cancellations (ie: remove the bubble if the app cancels the notification). + * + */ +@Singleton +public class BubbleCoordinator implements Coordinator { + private static final String TAG = "BubbleCoordinator"; + + private final BubbleController mBubbleController; + private final NotifCollection mNotifCollection; + private final Set<String> mInterceptedDismissalEntries = new HashSet<>(); + private NotifPipeline mNotifPipeline; + private NotifDismissInterceptor.OnEndDismissInterception mOnEndDismissInterception; + + @Inject + public BubbleCoordinator( + BubbleController bubbleController, + NotifCollection notifCollection) { + mBubbleController = bubbleController; + mNotifCollection = notifCollection; + } + + @Override + public void attach(NotifPipeline pipeline) { + mNotifPipeline = pipeline; + mNotifPipeline.addNotificationDismissInterceptor(mDismissInterceptor); + mNotifPipeline.addPreRenderFilter(mNotifFilter); + mBubbleController.addNotifCallback(mNotifCallback); + } + + private final NotifFilter mNotifFilter = new NotifFilter(TAG) { + @Override + public boolean shouldFilterOut(NotificationEntry entry, long now) { + return mBubbleController.isBubbleNotificationSuppressedFromShade(entry); + } + }; + + private final NotifDismissInterceptor mDismissInterceptor = new NotifDismissInterceptor() { + @Override + public String getName() { + return TAG; + } + + @Override + public void setCallback(OnEndDismissInterception callback) { + mOnEndDismissInterception = callback; + } + + @Override + public boolean shouldInterceptDismissal(NotificationEntry entry) { + // TODO: b/149041810 add support for intercepting app-cancelled bubble notifications + // for experimental bubbles + if (mBubbleController.handleDismissalInterception(entry)) { + mInterceptedDismissalEntries.add(entry.getKey()); + return true; + } else { + mInterceptedDismissalEntries.remove(entry.getKey()); + return false; + } + } + + @Override + public void cancelDismissInterception(NotificationEntry entry) { + mInterceptedDismissalEntries.remove(entry.getKey()); + } + }; + + private final BubbleController.NotifCallback mNotifCallback = + new BubbleController.NotifCallback() { + @Override + public void removeNotification(NotificationEntry entry, int reason) { + if (isInterceptingDismissal(entry)) { + mInterceptedDismissalEntries.remove(entry.getKey()); + mOnEndDismissInterception.onEndDismissInterception(mDismissInterceptor, entry, + createDismissedByUserStats(entry)); + } else if (mNotifPipeline.getActiveNotifs().contains(entry)) { + // Bubbles are hiding the notifications from the shade, but the bubble was + // deleted; therefore, the notification should be cancelled as if it were a user + // dismissal (this won't re-enter handleInterceptDimissal because Bubbles + // will have already marked it as no longer a bubble) + mNotifCollection.dismissNotification(entry, createDismissedByUserStats(entry)); + } + } + + @Override + public void invalidateNotifications(String reason) { + mNotifFilter.invalidateList(); + } + + @Override + public void maybeCancelSummary(NotificationEntry entry) { + // no-op + } + }; + + private boolean isInterceptingDismissal(NotificationEntry entry) { + return mInterceptedDismissalEntries.contains(entry.getKey()); + } + + private DismissedByUserStats createDismissedByUserStats(NotificationEntry entry) { + return new DismissedByUserStats( + DISMISSAL_OTHER, + DISMISS_SENTIMENT_UNKNOWN, + NotificationVisibility.obtain(entry.getKey(), + entry.getRanking().getRank(), + mNotifPipeline.getActiveNotifs().size(), + true, // was visible as a bubble + NotificationLogger.getNotificationLocation(entry)) + ); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java index 0a1e09f4c99d..7a9547c573bb 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java @@ -53,6 +53,7 @@ public class NotifCoordinators implements Dumpable { RankingCoordinator rankingCoordinator, ForegroundCoordinator foregroundCoordinator, DeviceProvisionedCoordinator deviceProvisionedCoordinator, + BubbleCoordinator bubbleCoordinator, PreparationCoordinator preparationCoordinator) { dumpController.registerDumpable(TAG, this); @@ -61,6 +62,7 @@ public class NotifCoordinators implements Dumpable { mCoordinators.add(rankingCoordinator); mCoordinators.add(foregroundCoordinator); mCoordinators.add(deviceProvisionedCoordinator); + mCoordinators.add(bubbleCoordinator); if (featureFlags.isNewNotifPipelineRenderingEnabled()) { mCoordinators.add(preparationCoordinator); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt index 14e15031056f..dc7a50d621a1 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt @@ -69,6 +69,14 @@ class NotifCollectionLogger @Inject constructor( }) } + fun logNotifDismissedIntercepted(key: String) { + buffer.log(TAG, INFO, { + str1 = key + }, { + "DISMISS INTERCEPTED $str1" + }) + } + fun logRankingMissing(key: String, rankingMap: RankingMap) { buffer.log(TAG, WARNING, { str1 = key }, { "Ranking update is missing ranking for $str1" }) buffer.log(TAG, DEBUG, {}, { "Ranking map contents:" }) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java new file mode 100644 index 000000000000..3354ad1daf20 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java @@ -0,0 +1,68 @@ +/* + * 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.systemui.statusbar.notification.collection.notifcollection; + +import com.android.systemui.statusbar.notification.collection.NotifCollection; +import com.android.systemui.statusbar.notification.collection.NotificationEntry; + +/** + * A way for coordinators to temporarily intercept a user-dismissed notification before a message + * is sent to system server to officially remove this notification. + * See {@link NotifCollection#addNotificationDismissInterceptor(NotifDismissInterceptor)}. + */ +public interface NotifDismissInterceptor { + /** Name to associate with this interceptor (for the purposes of debugging) */ + String getName(); + + /** + * Called on the interceptor immediately after it has been registered. The interceptor should + * hang on to this callback and execute it whenever it no longer needs to intercept the + * dismissal of the notification. + */ + void setCallback(OnEndDismissInterception callback); + + /** + * Called by the NotifCollection whenever a notification has been dismissed (by the user). + * If the interceptor returns true, it is considered to be intercepting the notification. + * Intercepted notifications will not be sent to system server for removal until it is no + * longer being intercepted. However, the notification can still be cancelled by the app. + * This method is called on all interceptors even if earlier ones return true. + */ + boolean shouldInterceptDismissal(NotificationEntry entry); + + + /** + * Called by the NotifCollection to inform a DismissInterceptor that its interception of a notif + * is no longer valid (usually because the notif has been removed by means other than the + * user dismissing the notification from the shade, or the notification has been updated). The + * interceptor should clean up any references it has to the notif in question. + */ + void cancelDismissInterception(NotificationEntry entry); + + /** + * Callback for notifying the NotifCollection that it no longer is intercepting the dismissal. + * If the end of this dismiss interception triggers a dismiss (ie: no other + * NotifDismissInterceptors are intercepting the entry), NotifCollection will use stats + * in the message sent to system server for the notification's dismissal. + */ + interface OnEndDismissInterception { + void onEndDismissInterception( + NotifDismissInterceptor interceptor, + NotificationEntry entry, + DismissedByUserStats stats); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java index 11f70796748c..c68d9942419b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java @@ -2466,10 +2466,6 @@ public class StatusBar extends SystemUI implements DemoMode, pw.println(" mHeadsUpManager: null"); } - if (mBubbleController != null) { - mBubbleController.dump(fd, pw, args); - } - if (mLightBarController != null) { mLightBarController.dump(fd, pw, args); } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java index 280d14e42c5f..c3b55e2ec168 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java @@ -20,8 +20,7 @@ import static android.app.Notification.FLAG_BUBBLE; import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL; import static android.service.notification.NotificationListenerService.REASON_CANCEL; import static android.service.notification.NotificationListenerService.REASON_CANCEL_ALL; - -import static com.android.systemui.statusbar.notification.NotificationEntryManager.UNDEFINED_DISMISS_REASON; +import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED; import static com.google.common.truth.Truth.assertThat; @@ -34,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -55,10 +55,12 @@ import android.view.WindowManager; import androidx.test.filters.SmallTest; import com.android.internal.colorextraction.ColorExtractor; +import com.android.systemui.DumpController; import com.android.systemui.SystemUIFactory; import com.android.systemui.SysuiTestCase; import com.android.systemui.colorextraction.SysuiColorExtractor; import com.android.systemui.plugins.statusbar.StatusBarStateController; +import com.android.systemui.statusbar.FeatureFlags; import com.android.systemui.statusbar.NotificationLockscreenUserManager; import com.android.systemui.statusbar.NotificationPresenter; import com.android.systemui.statusbar.NotificationRemoveInterceptor; @@ -68,6 +70,7 @@ import com.android.systemui.statusbar.notification.NotificationEntryListener; import com.android.systemui.statusbar.notification.NotificationEntryManager; import com.android.systemui.statusbar.notification.NotificationFilter; import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider; +import com.android.systemui.statusbar.notification.collection.NotifPipeline; import com.android.systemui.statusbar.notification.collection.NotificationEntry; import com.android.systemui.statusbar.notification.row.ActivatableNotificationView; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; @@ -92,6 +95,13 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.List; + +/** + * Tests the NotificationEntryManager setup with BubbleController. + * The {@link NotifPipeline} setup with BubbleController is tested in + * {@link NewNotifPipelineBubbleControllerTest}. + */ @SmallTest @RunWith(AndroidTestingRunner.class) @TestableLooper.RunWithLooper(setAsMainLooper = true) @@ -152,6 +162,12 @@ public class BubbleControllerTest extends SysuiTestCase { private ShadeController mShadeController; @Mock private NotificationRowComponent mNotificationRowComponent; + @Mock + private NotifPipeline mNotifPipeline; + @Mock + private FeatureFlags mFeatureFlagsOldPipeline; + @Mock + private DumpController mDumpController; private SuperStatusBarViewFactory mSuperStatusBarViewFactory; private BubbleData mBubbleData; @@ -213,6 +229,7 @@ public class BubbleControllerTest extends SysuiTestCase { mock(HeadsUpManager.class), mock(NotificationInterruptionStateProvider.HeadsUpSuppressor.class)); mBubbleData = new BubbleData(mContext); + when(mFeatureFlagsOldPipeline.isNewNotifPipelineRenderingEnabled()).thenReturn(false); mBubbleController = new TestableBubbleController(mContext, mNotificationShadeWindowController, mStatusBarStateController, @@ -223,7 +240,10 @@ public class BubbleControllerTest extends SysuiTestCase { mZenModeController, mLockscreenUserManager, mNotificationGroupManager, - mNotificationEntryManager); + mNotificationEntryManager, + mNotifPipeline, + mFeatureFlagsOldPipeline, + mDumpController); mBubbleController.setBubbleStateChangeListener(mBubbleStateChangeListener); mBubbleController.setExpandListener(mBubbleExpandListener); @@ -261,7 +281,7 @@ public class BubbleControllerTest extends SysuiTestCase { verify(mBubbleStateChangeListener).onHasBubblesChanged(true); mBubbleController.removeBubble( - mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE); + mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); assertFalse(mNotificationShadeWindowController.getBubblesShowing()); assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); verify(mNotificationEntryManager, times(2)).updateNotifications(anyString()); @@ -282,12 +302,12 @@ public class BubbleControllerTest extends SysuiTestCase { // Now remove the bubble mBubbleController.removeBubble( - mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE); + mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); // Since the notif is dismissed, once the bubble is removed, performRemoveNotification gets // called to really remove the notif verify(mNotificationEntryManager, times(1)).performRemoveNotification( - mRow.getEntry().getSbn(), UNDEFINED_DISMISS_REASON); + eq(mRow.getEntry().getSbn()), anyInt()); assertFalse(mBubbleController.hasBubbles()); } @@ -467,7 +487,7 @@ public class BubbleControllerTest extends SysuiTestCase { mRow2.getEntry())); // Dismiss currently expanded - mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(), + mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(), BubbleController.DISMISS_USER_GESTURE); verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey()); @@ -476,7 +496,7 @@ public class BubbleControllerTest extends SysuiTestCase { verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey()); // Dismiss that one - mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(), + mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(), BubbleController.DISMISS_USER_GESTURE); // Make sure state changes and collapse happens @@ -604,7 +624,7 @@ public class BubbleControllerTest extends SysuiTestCase { @Test public void testDeleteIntent_removeBubble_aged() throws PendingIntent.CanceledException { mBubbleController.updateBubble(mRow.getEntry()); - mBubbleController.removeBubble(mRow.getEntry().getKey(), BubbleController.DISMISS_AGED); + mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_AGED); verify(mDeleteIntent, never()).send(); } @@ -612,7 +632,7 @@ public class BubbleControllerTest extends SysuiTestCase { public void testDeleteIntent_removeBubble_user() throws PendingIntent.CanceledException { mBubbleController.updateBubble(mRow.getEntry()); mBubbleController.removeBubble( - mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE); + mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); verify(mDeleteIntent, times(1)).send(); } @@ -649,11 +669,22 @@ public class BubbleControllerTest extends SysuiTestCase { // Cancels always remove so no need to intercept assertFalse(intercepted); + } + + @Test + public void testRemoveBubble_entryListenerRemove() { + mEntryListener.onPendingEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + + // Removes the notification + mEntryListener.onEntryRemoved(mRow.getEntry(), null, false); assertFalse(mBubbleController.hasBubbles()); } @Test - public void removeBubble_fails_clearAll() { + public void removeBubble_clearAllIntercepted() { mEntryListener.onPendingEntryAdded(mRow.getEntry()); mBubbleController.updateBubble(mRow.getEntry()); @@ -669,14 +700,10 @@ public class BubbleControllerTest extends SysuiTestCase { // Should update show in shade state assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( mRow.getEntry())); - - verify(mNotificationEntryManager, never()).performRemoveNotification( - any(), anyInt()); - assertTrue(mBubbleController.hasBubbles()); } @Test - public void removeBubble_fails_userDismissNotif() { + public void removeBubble_userDismissNotifIntercepted() { mEntryListener.onPendingEntryAdded(mRow.getEntry()); mBubbleController.updateBubble(mRow.getEntry()); @@ -692,10 +719,6 @@ public class BubbleControllerTest extends SysuiTestCase { // Should update show in shade state assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( mRow.getEntry())); - - verify(mNotificationEntryManager, never()).performRemoveNotification( - any(), anyInt()); - assertTrue(mBubbleController.hasBubbles()); } @Test @@ -709,7 +732,7 @@ public class BubbleControllerTest extends SysuiTestCase { // Dismiss the bubble mBubbleController.removeBubble( - mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE); + mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); assertFalse(mBubbleController.hasBubbles()); // Dismiss the notification @@ -767,6 +790,74 @@ public class BubbleControllerTest extends SysuiTestCase { mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); } + @Test + public void testBubbleSummaryDismissal_suppressesSummaryAndBubbleFromShade() throws Exception { + // GIVEN a group summary with a bubble child + ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0); + ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup(); + mEntryListener.onPendingEntryAdded(groupedBubble.getEntry()); + groupSummary.addChildNotification(groupedBubble); + assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + + // WHEN the summary is dismissed + mBubbleController.handleDismissalInterception(groupSummary.getEntry()); + + // THEN the summary and bubbled child are suppressed from the shade + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + groupedBubble.getEntry())); + assertTrue(mBubbleData.isSummarySuppressed(groupSummary.getEntry().getSbn().getGroupKey())); + } + + @Test + public void testAppRemovesSummary_removesAllBubbleChildren() throws Exception { + // GIVEN a group summary with a bubble child + ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0); + ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup(); + mEntryListener.onPendingEntryAdded(groupedBubble.getEntry()); + groupSummary.addChildNotification(groupedBubble); + assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + + // GIVEN the summary is dismissed + mBubbleController.handleDismissalInterception(groupSummary.getEntry()); + + // WHEN the summary is cancelled by the app + mEntryListener.onEntryRemoved(groupSummary.getEntry(), null, true); + + // THEN the summary and its children are removed from bubble data + assertFalse(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + assertFalse(mBubbleData.isSummarySuppressed( + groupSummary.getEntry().getSbn().getGroupKey())); + } + + @Test + public void testSummaryDismissal_marksBubblesHiddenFromShadeAndDismissesNonBubbledChildren() + throws Exception { + // GIVEN a group summary with two (non-bubble) children and one bubble child + ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(2); + ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup(); + mEntryListener.onPendingEntryAdded(groupedBubble.getEntry()); + groupSummary.addChildNotification(groupedBubble); + + // WHEN the summary is dismissed + mBubbleController.handleDismissalInterception(groupSummary.getEntry()); + + // THEN only the NON-bubble children are dismissed + List<ExpandableNotificationRow> childrenRows = groupSummary.getNotificationChildren(); + verify(mNotificationEntryManager, times(1)).performRemoveNotification( + childrenRows.get(0).getEntry().getSbn(), REASON_GROUP_SUMMARY_CANCELED); + verify(mNotificationEntryManager, times(1)).performRemoveNotification( + childrenRows.get(1).getEntry().getSbn(), REASON_GROUP_SUMMARY_CANCELED); + verify(mNotificationEntryManager, never()).performRemoveNotification( + eq(groupedBubble.getEntry().getSbn()), anyInt()); + + // THEN the bubble child is suppressed from the shade + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + groupedBubble.getEntry())); + + // THEN the summary is removed from GroupManager + verify(mNotificationGroupManager, times(1)).onEntryRemoved(groupSummary.getEntry()); + } + static class TestableBubbleController extends BubbleController { // Let's assume surfaces can be synchronized immediately. TestableBubbleController(Context context, @@ -779,11 +870,15 @@ public class BubbleControllerTest extends SysuiTestCase { ZenModeController zenModeController, NotificationLockscreenUserManager lockscreenUserManager, NotificationGroupManager groupManager, - NotificationEntryManager entryManager) { + NotificationEntryManager entryManager, + NotifPipeline notifPipeline, + FeatureFlags featureFlags, + DumpController dumpController) { super(context, notificationShadeWindowController, statusBarStateController, shadeController, data, Runnable::run, configurationController, interruptionStateProvider, - zenModeController, lockscreenUserManager, groupManager, entryManager); + zenModeController, lockscreenUserManager, groupManager, entryManager, + notifPipeline, featureFlags, dumpController); setInflateSynchronously(true); } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java new file mode 100644 index 000000000000..72405fc519fa --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java @@ -0,0 +1,842 @@ +/* + * 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.systemui.bubbles; + +import static android.app.Notification.FLAG_BUBBLE; +import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.IActivityManager; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.res.Resources; +import android.hardware.face.FaceManager; +import android.service.notification.ZenModeConfig; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.view.WindowManager; + +import androidx.test.filters.SmallTest; + +import com.android.internal.colorextraction.ColorExtractor; +import com.android.systemui.DumpController; +import com.android.systemui.SystemUIFactory; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.colorextraction.SysuiColorExtractor; +import com.android.systemui.plugins.statusbar.StatusBarStateController; +import com.android.systemui.statusbar.FeatureFlags; +import com.android.systemui.statusbar.NotificationLockscreenUserManager; +import com.android.systemui.statusbar.NotificationPresenter; +import com.android.systemui.statusbar.SuperStatusBarViewFactory; +import com.android.systemui.statusbar.SysuiStatusBarStateController; +import com.android.systemui.statusbar.notification.NotificationEntryManager; +import com.android.systemui.statusbar.notification.NotificationFilter; +import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider; +import com.android.systemui.statusbar.notification.collection.NotifPipeline; +import com.android.systemui.statusbar.notification.collection.NotificationEntry; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener; +import com.android.systemui.statusbar.notification.row.ActivatableNotificationView; +import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; +import com.android.systemui.statusbar.notification.row.NotificationTestHelper; +import com.android.systemui.statusbar.notification.row.dagger.NotificationRowComponent; +import com.android.systemui.statusbar.phone.DozeParameters; +import com.android.systemui.statusbar.phone.KeyguardBypassController; +import com.android.systemui.statusbar.phone.NotificationGroupManager; +import com.android.systemui.statusbar.phone.NotificationShadeWindowController; +import com.android.systemui.statusbar.phone.ShadeController; +import com.android.systemui.statusbar.policy.BatteryController; +import com.android.systemui.statusbar.policy.ConfigurationController; +import com.android.systemui.statusbar.policy.HeadsUpManager; +import com.android.systemui.statusbar.policy.ZenModeController; +import com.android.systemui.util.InjectionInflationController; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +/** + * Tests the NotifPipeline setup with BubbleController. + * The NotificationEntryManager setup with BubbleController is tested in + * {@link BubbleControllerTest}. + */ +@SmallTest +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +public class NewNotifPipelineBubbleControllerTest extends SysuiTestCase { + @Mock + private NotificationEntryManager mNotificationEntryManager; + @Mock + private NotificationGroupManager mNotificationGroupManager; + @Mock + private BubbleController.NotifCallback mNotifCallback; + @Mock + private WindowManager mWindowManager; + @Mock + private IActivityManager mActivityManager; + @Mock + private DozeParameters mDozeParameters; + @Mock + private ConfigurationController mConfigurationController; + @Mock + private ZenModeController mZenModeController; + @Mock + private ZenModeConfig mZenModeConfig; + @Mock + private FaceManager mFaceManager; + @Mock + private NotificationLockscreenUserManager mLockscreenUserManager; + @Mock + private SysuiStatusBarStateController mStatusBarStateController; + @Mock + private KeyguardBypassController mKeyguardBypassController; + + @Captor + private ArgumentCaptor<NotifCollectionListener> mNotifListenerCaptor; + + private TestableBubbleController mBubbleController; + private NotificationShadeWindowController mNotificationShadeWindowController; + private NotifCollectionListener mEntryListener; + + private NotificationTestHelper mNotificationTestHelper; + private ExpandableNotificationRow mRow; + private ExpandableNotificationRow mRow2; + private ExpandableNotificationRow mNonBubbleNotifRow; + + @Mock + private BubbleController.BubbleStateChangeListener mBubbleStateChangeListener; + @Mock + private BubbleController.BubbleExpandListener mBubbleExpandListener; + @Mock + private PendingIntent mDeleteIntent; + @Mock + private SysuiColorExtractor mColorExtractor; + @Mock + ColorExtractor.GradientColors mGradientColors; + @Mock + private Resources mResources; + @Mock + private ShadeController mShadeController; + @Mock + private NotificationRowComponent mNotificationRowComponent; + @Mock + private NotifPipeline mNotifPipeline; + @Mock + private FeatureFlags mFeatureFlagsNewPipeline; + @Mock + private DumpController mDumpController; + + private SuperStatusBarViewFactory mSuperStatusBarViewFactory; + private BubbleData mBubbleData; + + private TestableLooper mTestableLooper; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mTestableLooper = TestableLooper.get(this); + + mContext.addMockSystemService(FaceManager.class, mFaceManager); + when(mColorExtractor.getNeutralColors()).thenReturn(mGradientColors); + + mSuperStatusBarViewFactory = new SuperStatusBarViewFactory(mContext, + new InjectionInflationController(SystemUIFactory.getInstance().getRootComponent()), + new NotificationRowComponent.Builder() { + @Override + public NotificationRowComponent.Builder activatableNotificationView( + ActivatableNotificationView view) { + return this; + } + + @Override + public NotificationRowComponent build() { + return mNotificationRowComponent; + } + }); + + // Bubbles get added to status bar window view + mNotificationShadeWindowController = new NotificationShadeWindowController(mContext, + mWindowManager, mActivityManager, mDozeParameters, mStatusBarStateController, + mConfigurationController, mKeyguardBypassController, mColorExtractor, + mSuperStatusBarViewFactory); + mNotificationShadeWindowController.attach(); + + // Need notifications for bubbles + mNotificationTestHelper = new NotificationTestHelper(mContext, mDependency); + mRow = mNotificationTestHelper.createBubble(mDeleteIntent); + mRow2 = mNotificationTestHelper.createBubble(mDeleteIntent); + mNonBubbleNotifRow = mNotificationTestHelper.createRow(); + + mZenModeConfig.suppressedVisualEffects = 0; + when(mZenModeController.getConfig()).thenReturn(mZenModeConfig); + + TestableNotificationInterruptionStateProvider interruptionStateProvider = + new TestableNotificationInterruptionStateProvider(mContext, + mock(NotificationFilter.class), + mock(StatusBarStateController.class), + mock(BatteryController.class)); + interruptionStateProvider.setUpWithPresenter( + mock(NotificationPresenter.class), + mock(HeadsUpManager.class), + mock(NotificationInterruptionStateProvider.HeadsUpSuppressor.class)); + mBubbleData = new BubbleData(mContext); + when(mFeatureFlagsNewPipeline.isNewNotifPipelineRenderingEnabled()).thenReturn(true); + mBubbleController = new TestableBubbleController(mContext, + mNotificationShadeWindowController, + mStatusBarStateController, + mShadeController, + mBubbleData, + mConfigurationController, + interruptionStateProvider, + mZenModeController, + mLockscreenUserManager, + mNotificationGroupManager, + mNotificationEntryManager, + mNotifPipeline, + mFeatureFlagsNewPipeline, + mDumpController); + mBubbleController.addNotifCallback(mNotifCallback); + mBubbleController.setBubbleStateChangeListener(mBubbleStateChangeListener); + mBubbleController.setExpandListener(mBubbleExpandListener); + + // Get a reference to the BubbleController's entry listener + verify(mNotifPipeline, atLeastOnce()) + .addCollectionListener(mNotifListenerCaptor.capture()); + mEntryListener = mNotifListenerCaptor.getValue(); + } + + @Test + public void testAddBubble() { + mBubbleController.updateBubble(mRow.getEntry()); + assertTrue(mBubbleController.hasBubbles()); + + verify(mBubbleStateChangeListener).onHasBubblesChanged(true); + } + + @Test + public void testHasBubbles() { + assertFalse(mBubbleController.hasBubbles()); + mBubbleController.updateBubble(mRow.getEntry()); + assertTrue(mBubbleController.hasBubbles()); + } + + @Test + public void testRemoveBubble() { + mBubbleController.updateBubble(mRow.getEntry()); + assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); + assertTrue(mBubbleController.hasBubbles()); + verify(mNotifCallback, times(1)).invalidateNotifications(anyString()); + verify(mBubbleStateChangeListener).onHasBubblesChanged(true); + + mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); + assertFalse(mNotificationShadeWindowController.getBubblesShowing()); + assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); + verify(mNotifCallback, times(2)).invalidateNotifications(anyString()); + verify(mBubbleStateChangeListener).onHasBubblesChanged(false); + } + + @Test + public void testRemoveBubble_withDismissedNotif() { + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry())); + + // Make it look like dismissed notif + mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).setSuppressNotification(true); + + // Now remove the bubble + mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); + + // Since the notif is dismissed, once the bubble is removed, removeNotification gets + // called to really remove the notif + verify(mNotifCallback, times(1)).removeNotification(eq(mRow.getEntry()), anyInt()); + assertFalse(mBubbleController.hasBubbles()); + } + + @Test + public void testDismissStack() { + mBubbleController.updateBubble(mRow.getEntry()); + verify(mNotifCallback, times(1)).invalidateNotifications(anyString()); + assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); + mBubbleController.updateBubble(mRow2.getEntry()); + verify(mNotifCallback, times(2)).invalidateNotifications(anyString()); + assertNotNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey())); + assertTrue(mBubbleController.hasBubbles()); + + mBubbleController.dismissStack(BubbleController.DISMISS_USER_GESTURE); + assertFalse(mNotificationShadeWindowController.getBubblesShowing()); + verify(mNotifCallback, times(3)).invalidateNotifications(anyString()); + assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); + assertNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey())); + } + + @Test + public void testExpandCollapseStack() { + assertFalse(mBubbleController.isStackExpanded()); + + // Mark it as a bubble and add it explicitly + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + // We should have bubbles & their notifs should not be suppressed + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + assertFalse(mNotificationShadeWindowController.getBubbleExpanded()); + + // Expand the stack + BubbleStackView stackView = mBubbleController.getStackView(); + mBubbleController.expandStack(); + assertTrue(mBubbleController.isStackExpanded()); + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey()); + assertTrue(mNotificationShadeWindowController.getBubbleExpanded()); + + // Make sure the notif is suppressed + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry())); + + // Collapse + mBubbleController.collapseStack(); + verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().getKey()); + assertFalse(mBubbleController.isStackExpanded()); + assertFalse(mNotificationShadeWindowController.getBubbleExpanded()); + } + + @Test + public void testCollapseAfterChangingExpandedBubble() { + // Mark it as a bubble and add it explicitly + mEntryListener.onEntryAdded(mRow.getEntry()); + mEntryListener.onEntryAdded(mRow2.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + mBubbleController.updateBubble(mRow2.getEntry()); + + // We should have bubbles & their notifs should not be suppressed + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow2.getEntry())); + + // Expand + BubbleStackView stackView = mBubbleController.getStackView(); + mBubbleController.expandStack(); + assertTrue(mBubbleController.isStackExpanded()); + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().getKey()); + + // Last added is the one that is expanded + assertEquals(mRow2.getEntry(), mBubbleData.getSelectedBubble().getEntry()); + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow2.getEntry())); + + // Switch which bubble is expanded + mBubbleController.selectBubble(mRow.getEntry().getKey()); + mBubbleData.setExpanded(true); + assertEquals(mRow.getEntry(), stackView.getExpandedBubble().getEntry()); + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + // collapse for previous bubble + verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey()); + // expand for selected bubble + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey()); + + // Collapse + mBubbleController.collapseStack(); + assertFalse(mBubbleController.isStackExpanded()); + } + + @Test + public void testExpansionRemovesShowInShadeAndDot() { + // Mark it as a bubble and add it explicitly + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + // We should have bubbles & their notifs should not be suppressed + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry())); + + mTestableLooper.processAllMessages(); + assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + + // Expand + mBubbleController.expandStack(); + assertTrue(mBubbleController.isStackExpanded()); + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey()); + + // Notif is suppressed after expansion + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + // Notif shouldn't show dot after expansion + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + } + + @Test + public void testUpdateWhileExpanded_DoesntChangeShowInShadeAndDot() { + // Mark it as a bubble and add it explicitly + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + // We should have bubbles & their notifs should not be suppressed + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + mTestableLooper.processAllMessages(); + assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + + // Expand + mBubbleController.expandStack(); + assertTrue(mBubbleController.isStackExpanded()); + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey()); + + // Notif is suppressed after expansion + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + // Notif shouldn't show dot after expansion + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + + // Send update + mEntryListener.onEntryUpdated(mRow.getEntry()); + + // Nothing should have changed + // Notif is suppressed after expansion + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + // Notif shouldn't show dot after expansion + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + } + + @Test + public void testRemoveLastExpandedCollapses() { + // Mark it as a bubble and add it explicitly + mEntryListener.onEntryAdded(mRow.getEntry()); + mEntryListener.onEntryAdded(mRow2.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + mBubbleController.updateBubble(mRow2.getEntry()); + verify(mBubbleStateChangeListener).onHasBubblesChanged(true); + + // Expand + BubbleStackView stackView = mBubbleController.getStackView(); + mBubbleController.expandStack(); + + assertTrue(mBubbleController.isStackExpanded()); + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().getKey()); + + // Last added is the one that is expanded + assertEquals(mRow2.getEntry(), stackView.getExpandedBubble().getEntry()); + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow2.getEntry())); + + // Dismiss currently expanded + mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(), + BubbleController.DISMISS_USER_GESTURE); + verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey()); + + // Make sure first bubble is selected + assertEquals(mRow.getEntry(), stackView.getExpandedBubble().getEntry()); + verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey()); + + // Dismiss that one + mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(), + BubbleController.DISMISS_USER_GESTURE); + + // Make sure state changes and collapse happens + verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().getKey()); + verify(mBubbleStateChangeListener).onHasBubblesChanged(false); + assertFalse(mBubbleController.hasBubbles()); + } + + @Test + public void testAutoExpand_fails_noFlag() { + assertFalse(mBubbleController.isStackExpanded()); + setMetadataFlags(mRow.getEntry(), + Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE, false /* enableFlag */); + + // Add the auto expand bubble + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + // Expansion shouldn't change + verify(mBubbleExpandListener, never()).onBubbleExpandChanged(false /* expanded */, + mRow.getEntry().getKey()); + assertFalse(mBubbleController.isStackExpanded()); + + // # of bubbles should change + verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */); + } + + @Test + public void testAutoExpand_succeeds_withFlag() { + setMetadataFlags(mRow.getEntry(), + Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE, true /* enableFlag */); + + // Add the auto expand bubble + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + // Expansion should change + verify(mBubbleExpandListener).onBubbleExpandChanged(true /* expanded */, + mRow.getEntry().getKey()); + assertTrue(mBubbleController.isStackExpanded()); + + // # of bubbles should change + verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */); + } + + @Test + public void testSuppressNotif_onInitialNotif() { + setMetadataFlags(mRow.getEntry(), + Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION, true /* enableFlag */); + + // Add the suppress notif bubble + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + // Notif should be suppressed because we were foreground + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + // Dot + flyout is hidden because notif is suppressed + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showFlyout()); + + // # of bubbles should change + verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */); + } + + @Test + public void testSuppressNotif_onUpdateNotif() { + mBubbleController.updateBubble(mRow.getEntry()); + + // Should not be suppressed + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + // Should show dot + assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + + // Update to suppress notif + setMetadataFlags(mRow.getEntry(), + Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION, true /* enableFlag */); + mBubbleController.updateBubble(mRow.getEntry()); + + // Notif should be suppressed + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + // Dot + flyout is hidden because notif is suppressed + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showFlyout()); + + // # of bubbles should change + verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */); + } + + @Test + public void testMarkNewNotificationAsShowInShade() { + mEntryListener.onEntryAdded(mRow.getEntry()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + mTestableLooper.processAllMessages(); + assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot()); + } + + @Test + public void testAddNotif_notBubble() { + mEntryListener.onEntryAdded(mNonBubbleNotifRow.getEntry()); + mEntryListener.onEntryUpdated(mNonBubbleNotifRow.getEntry()); + + verify(mBubbleStateChangeListener, never()).onHasBubblesChanged(anyBoolean()); + assertThat(mBubbleController.hasBubbles()).isFalse(); + } + + @Test + public void testDeleteIntent_removeBubble_aged() throws PendingIntent.CanceledException { + mBubbleController.updateBubble(mRow.getEntry()); + mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_AGED); + verify(mDeleteIntent, never()).send(); + } + + @Test + public void testDeleteIntent_removeBubble_user() throws PendingIntent.CanceledException { + mBubbleController.updateBubble(mRow.getEntry()); + mBubbleController.removeBubble( + mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); + verify(mDeleteIntent, times(1)).send(); + } + + @Test + public void testDeleteIntent_dismissStack() throws PendingIntent.CanceledException { + mBubbleController.updateBubble(mRow.getEntry()); + mBubbleController.updateBubble(mRow2.getEntry()); + mBubbleController.dismissStack(BubbleController.DISMISS_USER_GESTURE); + verify(mDeleteIntent, times(2)).send(); + } + + @Test + public void testRemoveBubble_noLongerBubbleAfterUpdate() + throws PendingIntent.CanceledException { + mBubbleController.updateBubble(mRow.getEntry()); + assertTrue(mBubbleController.hasBubbles()); + + mRow.getEntry().getSbn().getNotification().flags &= ~FLAG_BUBBLE; + mEntryListener.onEntryUpdated(mRow.getEntry()); + + assertFalse(mBubbleController.hasBubbles()); + verify(mDeleteIntent, never()).send(); + } + + @Test + public void testRemoveBubble_entryListenerRemove() { + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + + // Removes the notification + mEntryListener.onEntryRemoved(mRow.getEntry(), 0); + assertFalse(mBubbleController.hasBubbles()); + } + + @Test + public void removeBubble_intercepted() { + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + boolean intercepted = mBubbleController.handleDismissalInterception(mRow.getEntry()); + + // Intercept! + assertTrue(intercepted); + // Should update show in shade state + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry())); + } + + @Test + public void removeBubble_succeeds_userDismissBubble_userDimissNotif() { + mEntryListener.onEntryAdded(mRow.getEntry()); + mBubbleController.updateBubble(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + // Dismiss the bubble + mBubbleController.removeBubble( + mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE); + assertFalse(mBubbleController.hasBubbles()); + + // Dismiss the notification + boolean intercepted = mBubbleController.handleDismissalInterception(mRow.getEntry()); + + // It's no longer a bubble so we shouldn't intercept + assertFalse(intercepted); + } + + @Test + public void testNotifyShadeSuppressionChange_notificationDismiss() { + BubbleController.NotificationSuppressionChangedListener listener = + mock(BubbleController.NotificationSuppressionChangedListener.class); + mBubbleData.setSuppressionChangedListener(listener); + + mEntryListener.onEntryAdded(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + mBubbleController.handleDismissalInterception(mRow.getEntry()); + + // Should update show in shade state + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + // Should notify delegate that shade state changed + verify(listener).onBubbleNotificationSuppressionChange( + mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); + } + + @Test + public void testNotifyShadeSuppressionChange_bubbleExpanded() { + BubbleController.NotificationSuppressionChangedListener listener = + mock(BubbleController.NotificationSuppressionChangedListener.class); + mBubbleData.setSuppressionChangedListener(listener); + + mEntryListener.onEntryAdded(mRow.getEntry()); + + assertTrue(mBubbleController.hasBubbles()); + assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + mBubbleData.setExpanded(true); + + // Once a bubble is expanded the notif is suppressed + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + mRow.getEntry())); + + // Should notify delegate that shade state changed + verify(listener).onBubbleNotificationSuppressionChange( + mBubbleData.getBubbleWithKey(mRow.getEntry().getKey())); + } + + @Test + public void testBubbleSummaryDismissal_suppressesSummaryAndBubbleFromShade() throws Exception { + // GIVEN a group summary with a bubble child + ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0); + ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup(); + mEntryListener.onEntryAdded(groupedBubble.getEntry()); + groupSummary.addChildNotification(groupedBubble); + assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + + // WHEN the summary is dismissed + mBubbleController.handleDismissalInterception(groupSummary.getEntry()); + + // THEN the summary and bubbled child are suppressed from the shade + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + groupedBubble.getEntry())); + assertTrue(mBubbleData.isSummarySuppressed(groupSummary.getEntry().getSbn().getGroupKey())); + } + + @Test + public void testAppRemovesSummary_removesAllBubbleChildren() throws Exception { + // GIVEN a group summary with a bubble child + ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0); + ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup(); + mEntryListener.onEntryAdded(groupedBubble.getEntry()); + groupSummary.addChildNotification(groupedBubble); + assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + + // GIVEN the summary is dismissed + mBubbleController.handleDismissalInterception(groupSummary.getEntry()); + + // WHEN the summary is cancelled by the app + mEntryListener.onEntryRemoved(groupSummary.getEntry(), 0); + + // THEN the summary and its children are removed from bubble data + assertFalse(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + assertFalse(mBubbleData.isSummarySuppressed( + groupSummary.getEntry().getSbn().getGroupKey())); + } + + @Test + public void testSummaryDismissalMarksBubblesHiddenFromShadeAndDismissesNonBubbledChildren() + throws Exception { + // GIVEN a group summary with two (non-bubble) children and one bubble child + ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(2); + ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup(); + mEntryListener.onEntryAdded(groupedBubble.getEntry()); + groupSummary.addChildNotification(groupedBubble); + + // WHEN the summary is dismissed + mBubbleController.handleDismissalInterception(groupSummary.getEntry()); + + // THEN only the NON-bubble children are dismissed + List<ExpandableNotificationRow> childrenRows = groupSummary.getNotificationChildren(); + verify(mNotifCallback, times(1)).removeNotification( + childrenRows.get(0).getEntry(), REASON_GROUP_SUMMARY_CANCELED); + verify(mNotifCallback, times(1)).removeNotification( + childrenRows.get(1).getEntry(), REASON_GROUP_SUMMARY_CANCELED); + verify(mNotifCallback, never()).removeNotification(eq(groupedBubble.getEntry()), anyInt()); + + // THEN the bubble child still exists as a bubble and is suppressed from the shade + assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey())); + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + groupedBubble.getEntry())); + + // THEN the summary is also suppressed from the shade + assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade( + groupSummary.getEntry())); + } + + static class TestableBubbleController extends BubbleController { + // Let's assume surfaces can be synchronized immediately. + TestableBubbleController(Context context, + NotificationShadeWindowController notificationShadeWindowController, + StatusBarStateController statusBarStateController, + ShadeController shadeController, + BubbleData data, + ConfigurationController configurationController, + NotificationInterruptionStateProvider interruptionStateProvider, + ZenModeController zenModeController, + NotificationLockscreenUserManager lockscreenUserManager, + NotificationGroupManager groupManager, + NotificationEntryManager entryManager, + NotifPipeline notifPipeline, + FeatureFlags featureFlags, + DumpController dumpController) { + super(context, + notificationShadeWindowController, statusBarStateController, shadeController, + data, Runnable::run, configurationController, interruptionStateProvider, + zenModeController, lockscreenUserManager, groupManager, entryManager, + notifPipeline, featureFlags, dumpController); + setInflateSynchronously(true); + } + } + + static class TestableNotificationInterruptionStateProvider extends + NotificationInterruptionStateProvider { + + TestableNotificationInterruptionStateProvider(Context context, + NotificationFilter filter, StatusBarStateController controller, + BatteryController batteryController) { + super(context, filter, controller, batteryController); + mUseHeadsUp = true; + } + } + + /** + * Sets the bubble metadata flags for this entry. These flags are normally set by + * NotificationManagerService when the notification is sent, however, these tests do not + * go through that path so we set them explicitly when testing. + */ + private void setMetadataFlags(NotificationEntry entry, int flag, boolean enableFlag) { + Notification.BubbleMetadata bubbleMetadata = + entry.getSbn().getNotification().getBubbleMetadata(); + int flags = bubbleMetadata.getFlags(); + if (enableFlag) { + flags |= flag; + } else { + flags &= ~flag; + } + bubbleMetadata.setFlags(flags); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java index 7c94ed20e95a..abc0f3ee8a52 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java @@ -34,6 +34,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyObject; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; @@ -47,6 +48,7 @@ import static java.util.Objects.requireNonNull; import android.annotation.Nullable; import android.os.RemoteException; import android.service.notification.NotificationListenerService.Ranking; +import android.service.notification.NotificationStats; import android.testing.AndroidTestingRunner; import android.testing.TestableLooper; import android.util.ArrayMap; @@ -69,6 +71,7 @@ import com.android.systemui.statusbar.notification.collection.notifcollection.Co import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionLogger; +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor; import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender; import com.android.systemui.util.Assert; @@ -98,11 +101,19 @@ public class NotifCollectionTest extends SysuiTestCase { @Spy private RecordingCollectionListener mCollectionListener; @Mock private CollectionReadyForBuildListener mBuildListener; @Mock private FeatureFlags mFeatureFlags; + @Mock private DismissedByUserStats mDismissedByUserStats; @Spy private RecordingLifetimeExtender mExtender1 = new RecordingLifetimeExtender("Extender1"); @Spy private RecordingLifetimeExtender mExtender2 = new RecordingLifetimeExtender("Extender2"); @Spy private RecordingLifetimeExtender mExtender3 = new RecordingLifetimeExtender("Extender3"); + @Spy private RecordingDismissInterceptor mInterceptor1 = new RecordingDismissInterceptor( + "Interceptor1"); + @Spy private RecordingDismissInterceptor mInterceptor2 = new RecordingDismissInterceptor( + "Interceptor2"); + @Spy private RecordingDismissInterceptor mInterceptor3 = new RecordingDismissInterceptor( + "Interceptor3"); + @Captor private ArgumentCaptor<BatchableNotificationHandler> mListenerCaptor; @Captor private ArgumentCaptor<NotificationEntry> mEntryCaptor; @Captor private ArgumentCaptor<Collection<NotificationEntry>> mBuildListCaptor; @@ -441,6 +452,169 @@ public class NotifCollectionTest extends SysuiTestCase { assertEquals(NOT_DISMISSED, entry3.getDismissState()); } + @Test + public void testDismissInterceptorsAreCalled() throws RemoteException { + // GIVEN a collection with notifications with multiple dismiss interceptors + mInterceptor1.shouldInterceptDismissal = true; + mInterceptor2.shouldInterceptDismissal = true; + mInterceptor3.shouldInterceptDismissal = false; + mCollection.addNotificationDismissInterceptor(mInterceptor1); + mCollection.addNotificationDismissInterceptor(mInterceptor2); + mCollection.addNotificationDismissInterceptor(mInterceptor3); + + NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag")); + NotificationEntry entry = mCollectionListener.getEntry(notif.key); + + // WHEN a notification is manually dismissed + DismissedByUserStats stats = new DismissedByUserStats( + NotificationStats.DISMISSAL_SHADE, + NotificationStats.DISMISS_SENTIMENT_NEUTRAL, + NotificationVisibility.obtain(entry.getKey(), 7, 2, true)); + mCollection.dismissNotification(entry, stats); + + // THEN all interceptors get checked + verify(mInterceptor1).shouldInterceptDismissal(entry); + verify(mInterceptor2).shouldInterceptDismissal(entry); + verify(mInterceptor3).shouldInterceptDismissal(entry); + assertEquals(List.of(mInterceptor1, mInterceptor2), entry.mDismissInterceptors); + + // THEN we never send the dismissal to system server + verify(mStatusBarService, never()).onNotificationClear( + notif.sbn.getPackageName(), + notif.sbn.getTag(), + 47, + notif.sbn.getUser().getIdentifier(), + notif.sbn.getKey(), + stats.dismissalSurface, + stats.dismissalSentiment, + stats.notificationVisibility); + } + + @Test + public void testDismissInterceptorsCanceledWhenNotifIsUpdated() throws RemoteException { + // GIVEN a few lifetime extenders and a couple notifications + mCollection.addNotificationDismissInterceptor(mInterceptor1); + mCollection.addNotificationDismissInterceptor(mInterceptor2); + + mInterceptor1.shouldInterceptDismissal = true; + mInterceptor2.shouldInterceptDismissal = true; + + NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + NotificationEntry entry = mCollectionListener.getEntry(notif.key); + + // WHEN a notification is manually dismissed and intercepted + DismissedByUserStats stats = new DismissedByUserStats( + NotificationStats.DISMISSAL_SHADE, + NotificationStats.DISMISS_SENTIMENT_NEUTRAL, + NotificationVisibility.obtain(entry.getKey(), 7, 2, true)); + mCollection.dismissNotification(entry, stats); + assertEquals(List.of(mInterceptor1, mInterceptor2), entry.mDismissInterceptors); + clearInvocations(mInterceptor1, mInterceptor2); + + // WHEN the notification is reposted + mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + + // THEN all of the active dismissal interceptors are canceled + verify(mInterceptor1).cancelDismissInterception(entry); + verify(mInterceptor2).cancelDismissInterception(entry); + assertEquals(List.of(), entry.mDismissInterceptors); + + // THEN the notification is never sent to system server to dismiss + verify(mStatusBarService, never()).onNotificationClear( + eq(notif.sbn.getPackageName()), + eq(notif.sbn.getTag()), + eq(47), + eq(notif.sbn.getUser().getIdentifier()), + eq(notif.sbn.getKey()), + anyInt(), + anyInt(), + anyObject()); + } + + @Test + public void testEndingAllDismissInterceptorsSendsDismiss() throws RemoteException { + // GIVEN a collection with notifications a dismiss interceptor + mInterceptor1.shouldInterceptDismissal = true; + mCollection.addNotificationDismissInterceptor(mInterceptor1); + + NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag")); + NotificationEntry entry = mCollectionListener.getEntry(notif.key); + + // GIVEN a notification is manually dismissed + DismissedByUserStats stats = new DismissedByUserStats( + NotificationStats.DISMISSAL_SHADE, + NotificationStats.DISMISS_SENTIMENT_NEUTRAL, + NotificationVisibility.obtain(entry.getKey(), 7, 2, true)); + mCollection.dismissNotification(entry, stats); + + // WHEN all interceptors end their interception dismissal + mInterceptor1.shouldInterceptDismissal = false; + mInterceptor1.onEndInterceptionCallback.onEndDismissInterception(mInterceptor1, entry, + mDismissedByUserStats); + + // THEN we send the dismissal to system server + verify(mStatusBarService, times(1)).onNotificationClear( + eq(notif.sbn.getPackageName()), + eq(notif.sbn.getTag()), + eq(47), + eq(notif.sbn.getUser().getIdentifier()), + eq(notif.sbn.getKey()), + anyInt(), + anyInt(), + anyObject()); + } + + @Test + public void testEndDismissInterceptionUpdatesDismissInterceptors() throws RemoteException { + // GIVEN a collection with notifications with multiple dismiss interceptors + mInterceptor1.shouldInterceptDismissal = true; + mInterceptor2.shouldInterceptDismissal = true; + mInterceptor3.shouldInterceptDismissal = false; + mCollection.addNotificationDismissInterceptor(mInterceptor1); + mCollection.addNotificationDismissInterceptor(mInterceptor2); + mCollection.addNotificationDismissInterceptor(mInterceptor3); + + NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag")); + NotificationEntry entry = mCollectionListener.getEntry(notif.key); + + // GIVEN a notification is manually dismissed + DismissedByUserStats stats = new DismissedByUserStats( + NotificationStats.DISMISSAL_SHADE, + NotificationStats.DISMISS_SENTIMENT_NEUTRAL, + NotificationVisibility.obtain(entry.getKey(), 7, 2, true)); + mCollection.dismissNotification(entry, stats); + + // WHEN an interceptor ends its interception + mInterceptor1.shouldInterceptDismissal = false; + mInterceptor1.onEndInterceptionCallback.onEndDismissInterception(mInterceptor1, entry, + mDismissedByUserStats); + + // THEN all interceptors get checked + verify(mInterceptor1).shouldInterceptDismissal(entry); + verify(mInterceptor2).shouldInterceptDismissal(entry); + verify(mInterceptor3).shouldInterceptDismissal(entry); + + // THEN mInterceptor2 is the only dismiss interceptor + assertEquals(List.of(mInterceptor2), entry.mDismissInterceptors); + } + + + @Test(expected = IllegalStateException.class) + public void testEndingDismissalOfNonInterceptedThrows() throws RemoteException { + // GIVEN a collection with notifications with a dismiss interceptor that hasn't been called + mInterceptor1.shouldInterceptDismissal = false; + mCollection.addNotificationDismissInterceptor(mInterceptor1); + + NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag")); + NotificationEntry entry = mCollectionListener.getEntry(notif.key); + + // WHEN we try to end the dismissal of an interceptor that didn't intercept the notif + mInterceptor1.onEndInterceptionCallback.onEndDismissInterception(mInterceptor1, entry, + mDismissedByUserStats); + + // THEN an exception is thrown + } + @Test(expected = IllegalStateException.class) public void testDismissingNonExistentNotificationThrows() { // GIVEN a collection that originally had three notifs, but where one was dismissed @@ -894,6 +1068,36 @@ public class NotifCollectionTest extends SysuiTestCase { } } + private static class RecordingDismissInterceptor implements NotifDismissInterceptor { + private final String mName; + + public @Nullable OnEndDismissInterception onEndInterceptionCallback; + public boolean shouldInterceptDismissal = false; + + private RecordingDismissInterceptor(String name) { + mName = name; + } + + @Override + public String getName() { + return mName; + } + + @Override + public void setCallback(OnEndDismissInterception callback) { + this.onEndInterceptionCallback = callback; + } + + @Override + public boolean shouldInterceptDismissal(NotificationEntry entry) { + return shouldInterceptDismissal; + } + + @Override + public void cancelDismissInterception(NotificationEntry entry) { + } + } + private static final String TEST_PACKAGE = "com.android.test.collection"; private static final String TEST_PACKAGE2 = "com.android.test.collection2"; diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java index 4283fad83a23..35b55087873b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java @@ -201,9 +201,17 @@ public class NotificationTestHelper { /** * Returns an {@link ExpandableNotificationRow} that should be shown as a bubble. */ + public ExpandableNotificationRow createBubbleInGroup() + throws Exception { + return createBubble(makeBubbleMetadata(null), PKG, true); + } + + /** + * Returns an {@link ExpandableNotificationRow} that should be shown as a bubble. + */ public ExpandableNotificationRow createBubble() throws Exception { - return createBubble(makeBubbleMetadata(null), PKG); + return createBubble(makeBubbleMetadata(null), PKG, false); } /** @@ -213,7 +221,7 @@ public class NotificationTestHelper { */ public ExpandableNotificationRow createBubble(@Nullable PendingIntent deleteIntent) throws Exception { - return createBubble(makeBubbleMetadata(deleteIntent), PKG); + return createBubble(makeBubbleMetadata(deleteIntent), PKG, false); } /** @@ -223,8 +231,14 @@ public class NotificationTestHelper { */ public ExpandableNotificationRow createBubble(BubbleMetadata bubbleMetadata, String pkg) throws Exception { + return createBubble(bubbleMetadata, pkg, false); + } + + private ExpandableNotificationRow createBubble(BubbleMetadata bubbleMetadata, String pkg, + boolean inGroup) + throws Exception { Notification n = createNotification(false /* isGroupSummary */, - null /* groupKey */, bubbleMetadata); + inGroup ? GROUP_KEY : null /* groupKey */, bubbleMetadata); n.flags |= FLAG_BUBBLE; ExpandableNotificationRow row = generateRow(n, pkg, UID, USER_HANDLE, 0 /* extraInflationFlags */, IMPORTANCE_HIGH); |