Merge "Optimistically dismiss children of dismissed groups"

This commit is contained in:
Ned Burns
2020-02-04 04:18:40 +00:00
committed by Android (Google) Code Review
11 changed files with 545 additions and 136 deletions

View File

@@ -89,7 +89,7 @@ public class ForegroundServiceNotificationListener {
}
@Override
public void onEntryRemoved(NotificationEntry entry, int reason, boolean removedByUser) {
public void onEntryRemoved(NotificationEntry entry, int reason) {
removeNotification(entry.getSbn());
}
});

View File

@@ -16,6 +16,9 @@
package com.android.systemui.statusbar.notification.collection;
import static com.android.systemui.statusbar.notification.collection.NotifCollection.REASON_NOT_CANCELED;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.NOT_DISMISSED;
import java.util.Arrays;
import java.util.List;
@@ -132,8 +135,20 @@ public class ListDumper {
.append(" ");
}
if (notifEntry.mCancellationReason != REASON_NOT_CANCELED) {
rksb.append("cancellationReason=")
.append(notifEntry.mCancellationReason)
.append(" ");
}
if (notifEntry.hasInflationError()) {
rksb.append("hasInflationError ");
rksb.append("(!)hasInflationError ");
}
if (notifEntry.getDismissState() != NOT_DISMISSED) {
rksb.append("dismissState=")
.append(notifEntry.getDismissState())
.append(" ");
}
String rkString = rksb.toString();

View File

@@ -35,9 +35,14 @@ import static android.service.notification.NotificationListenerService.REASON_TI
import static android.service.notification.NotificationListenerService.REASON_UNAUTOBUNDLED;
import static android.service.notification.NotificationListenerService.REASON_USER_STOPPED;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.DISMISSED;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.NOT_DISMISSED;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.PARENT_DISMISSED;
import static java.util.Objects.requireNonNull;
import android.annotation.IntDef;
import android.annotation.MainThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.os.RemoteException;
import android.service.notification.NotificationListenerService.Ranking;
@@ -45,6 +50,8 @@ import android.service.notification.NotificationListenerService.RankingMap;
import android.service.notification.StatusBarNotification;
import android.util.ArrayMap;
import androidx.annotation.NonNull;
import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.DumpController;
import com.android.systemui.Dumpable;
@@ -172,15 +179,65 @@ public class NotifCollection implements Dumpable {
/**
* Dismiss a notification on behalf of the user.
*/
void dismissNotification(
NotificationEntry entry,
@CancellationReason int reason,
@NonNull DismissedByUserStats stats) {
void dismissNotification(NotificationEntry entry, @NonNull DismissedByUserStats stats) {
Assert.isMainThread();
Objects.requireNonNull(stats);
requireNonNull(stats);
checkForReentrantCall();
removeNotification(entry.getKey(), null, reason, stats);
if (entry != mNotificationSet.get(entry.getKey())) {
throw new IllegalStateException("Invalid entry: " + entry.getKey());
}
if (entry.getDismissState() == DISMISSED) {
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);
mLogger.logNotifDismissed(entry.getKey());
List<NotificationEntry> canceledEntries = new ArrayList<>();
if (isCanceled(entry)) {
canceledEntries.add(entry);
} else {
// Ask system server to remove it for us
try {
mStatusBarService.onNotificationClear(
entry.getSbn().getPackageName(),
entry.getSbn().getTag(),
entry.getSbn().getId(),
entry.getSbn().getUser().getIdentifier(),
entry.getSbn().getKey(),
stats.dismissalSurface,
stats.dismissalSentiment,
stats.notificationVisibility);
} catch (RemoteException e) {
// system process is dead if we're here.
}
// Also mark any children as dismissed as system server will auto-dismiss them as well
if (entry.getSbn().getNotification().isGroupSummary()) {
for (NotificationEntry otherEntry : mNotificationSet.values()) {
if (otherEntry.getSbn().getGroupKey().equals(entry.getSbn().getGroupKey())
&& otherEntry.getDismissState() != DISMISSED) {
otherEntry.setDismissState(PARENT_DISMISSED);
if (isCanceled(otherEntry)) {
canceledEntries.add(otherEntry);
}
}
}
}
}
// Immediately remove any dismissed notifs that have already been canceled by system server
// (probably due to being lifetime-extended up until this point).
for (NotificationEntry canceledEntry : canceledEntries) {
tryRemoveNotification(canceledEntry);
}
rebuildList();
}
private void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
@@ -208,7 +265,15 @@ public class NotifCollection implements Dumpable {
Assert.isMainThread();
mLogger.logNotifRemoved(sbn.getKey(), reason);
removeNotification(sbn.getKey(), rankingMap, reason, null);
final NotificationEntry entry = mNotificationSet.get(sbn.getKey());
if (entry == null) {
throw new IllegalStateException("No notification to remove with key " + sbn.getKey());
}
entry.mCancellationReason = reason;
applyRanking(rankingMap);
tryRemoveNotification(entry);
rebuildList();
}
private void onNotificationRankingUpdate(RankingMap rankingMap) {
@@ -242,9 +307,12 @@ 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. Don't
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);
entry.mCancellationReason = REASON_NOT_CANCELED;
entry.setSbn(sbn);
if (rankingMap != null) {
@@ -255,59 +323,42 @@ public class NotifCollection implements Dumpable {
}
}
private void removeNotification(
String key,
@Nullable RankingMap rankingMap,
@CancellationReason int reason,
@Nullable DismissedByUserStats dismissedByUserStats) {
NotificationEntry entry = mNotificationSet.get(key);
if (entry == null) {
throw new IllegalStateException("No notification to remove with key " + key);
/**
* Tries to remove a notification from the notification set. This removal may be blocked by
* lifetime extenders. Does not trigger a rebuild of the list; caller must do that manually.
*
* @return True if the notification was removed, false otherwise.
*/
private boolean tryRemoveNotification(NotificationEntry entry) {
if (mNotificationSet.get(entry.getKey()) != entry) {
throw new IllegalStateException("No notification to remove with key " + entry.getKey());
}
entry.mLifetimeExtenders.clear();
mAmDispatchingToOtherCode = true;
for (NotifLifetimeExtender extender : mLifetimeExtenders) {
if (extender.shouldExtendLifetime(entry, reason)) {
entry.mLifetimeExtenders.add(extender);
}
if (!isCanceled(entry)) {
throw new IllegalStateException("Cannot remove notification " + entry.getKey()
+ ": has not been marked for removal");
}
if (isDismissedByUser(entry)) {
// User-dismissed notifications cannot be lifetime-extended
cancelLifetimeExtension(entry);
} else {
updateLifetimeExtension(entry);
}
mAmDispatchingToOtherCode = false;
if (!isLifetimeExtended(entry)) {
mNotificationSet.remove(entry.getKey());
if (dismissedByUserStats != null) {
try {
mStatusBarService.onNotificationClear(
entry.getSbn().getPackageName(),
entry.getSbn().getTag(),
entry.getSbn().getId(),
entry.getSbn().getUser().getIdentifier(),
entry.getSbn().getKey(),
dismissedByUserStats.dismissalSurface,
dismissedByUserStats.dismissalSentiment,
dismissedByUserStats.notificationVisibility);
} catch (RemoteException e) {
// system process is dead if we're here.
}
}
if (rankingMap != null) {
applyRanking(rankingMap);
}
dispatchOnEntryRemoved(entry, reason, dismissedByUserStats != null /* removedByUser */);
dispatchOnEntryRemoved(entry, entry.mCancellationReason);
dispatchOnEntryCleanUp(entry);
return true;
} else {
return false;
}
rebuildList();
}
private void applyRanking(@NonNull RankingMap rankingMap) {
for (NotificationEntry entry : mNotificationSet.values()) {
if (!isLifetimeExtended(entry)) {
if (!isCanceled(entry)) {
// TODO: (b/148791039) We should crash if we are ever handed a ranking with
// incomplete entries. Right now, there's a race condition in NotificationListener
@@ -355,9 +406,9 @@ public class NotifCollection implements Dumpable {
}
if (!isLifetimeExtended(entry)) {
// TODO: This doesn't need to be undefined -- we can set either EXTENDER_EXPIRED or
// save the original reason
removeNotification(entry.getKey(), null, REASON_UNKNOWN, null);
if (tryRemoveNotification(entry)) {
rebuildList();
}
}
}
@@ -374,6 +425,31 @@ public class NotifCollection implements Dumpable {
return entry.mLifetimeExtenders.size() > 0;
}
private void updateLifetimeExtension(NotificationEntry entry) {
entry.mLifetimeExtenders.clear();
mAmDispatchingToOtherCode = true;
for (NotifLifetimeExtender extender : mLifetimeExtenders) {
if (extender.shouldExtendLifetime(entry, entry.mCancellationReason)) {
entry.mLifetimeExtenders.add(extender);
}
}
mAmDispatchingToOtherCode = false;
}
private void cancelLocalDismissal(NotificationEntry entry) {
if (isDismissedByUser(entry)) {
entry.setDismissState(NOT_DISMISSED);
if (entry.getSbn().getNotification().isGroupSummary()) {
for (NotificationEntry otherEntry : mNotificationSet.values()) {
if (otherEntry.getSbn().getGroupKey().equals(entry.getSbn().getGroupKey())
&& otherEntry.getDismissState() == PARENT_DISMISSED) {
otherEntry.setDismissState(NOT_DISMISSED);
}
}
}
}
}
private void checkForReentrantCall() {
if (mAmDispatchingToOtherCode) {
throw new IllegalStateException("Reentrant call detected");
@@ -389,6 +465,19 @@ public class NotifCollection implements Dumpable {
return ranking;
}
/**
* True if the notification has been canceled by system server. Usually, such notifications are
* immediately removed from the collection, but can sometimes stick around due to lifetime
* extenders.
*/
private static boolean isCanceled(NotificationEntry entry) {
return entry.mCancellationReason != REASON_NOT_CANCELED;
}
private static boolean isDismissedByUser(NotificationEntry entry) {
return entry.getDismissState() != NOT_DISMISSED;
}
private void dispatchOnEntryInit(NotificationEntry entry) {
mAmDispatchingToOtherCode = true;
for (NotifCollectionListener listener : mNotifCollectionListeners) {
@@ -421,13 +510,10 @@ public class NotifCollection implements Dumpable {
mAmDispatchingToOtherCode = false;
}
private void dispatchOnEntryRemoved(
NotificationEntry entry,
@CancellationReason int reason,
boolean removedByUser) {
private void dispatchOnEntryRemoved(NotificationEntry entry, @CancellationReason int reason) {
mAmDispatchingToOtherCode = true;
for (NotifCollectionListener listener : mNotifCollectionListeners) {
listener.onEntryRemoved(entry, reason, removedByUser);
listener.onEntryRemoved(entry, reason);
}
mAmDispatchingToOtherCode = false;
}
@@ -439,6 +525,20 @@ public class NotifCollection implements Dumpable {
}
mAmDispatchingToOtherCode = false;
}
@Override
public void dump(@NonNull FileDescriptor fd, PrintWriter pw, @NonNull String[] args) {
final List<NotificationEntry> entries = new ArrayList<>(getActiveNotifs());
pw.println("\t" + TAG + " unsorted/unfiltered notifications:");
if (entries.size() == 0) {
pw.println("\t\t None");
}
pw.println(
ListDumper.dumpList(
entries,
true,
"\t\t"));
}
private final BatchableNotificationHandler mNotifHandler = new BatchableNotificationHandler() {
@Override
@@ -494,20 +594,6 @@ public class NotifCollection implements Dumpable {
@Retention(RetentionPolicy.SOURCE)
public @interface CancellationReason {}
public static final int REASON_NOT_CANCELED = -1;
public static final int REASON_UNKNOWN = 0;
@Override
public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
final List<NotificationEntry> entries = new ArrayList<>(getActiveNotifs());
pw.println("\t" + TAG + " unsorted/unfiltered notifications:");
if (entries.size() == 0) {
pw.println("\t\t None");
}
pw.println(
ListDumper.dumpList(
entries,
true,
"\t\t"));
}
}

View File

@@ -98,13 +98,12 @@ public class NotifInflaterImpl implements NotifInflater {
@Override
public void run() {
int dismissalSurface = NotificationStats.DISMISSAL_SHADE;
/**
/*
* TODO: determine dismissal surface (ie: shade / headsup / aod)
* see {@link NotificationLogger#logNotificationClear}
*/
mNotifCollection.dismissNotification(
entry,
0,
new DismissedByUserStats(
dismissalSurface,
DISMISS_SENTIMENT_NEUTRAL,

View File

@@ -29,9 +29,11 @@ import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_NOTIFICAT
import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_PEEK;
import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_STATUS_BAR;
import static com.android.systemui.statusbar.notification.collection.NotifCollection.REASON_NOT_CANCELED;
import static com.android.systemui.statusbar.notification.stack.NotificationSectionsManager.BUCKET_ALERTING;
import android.annotation.NonNull;
import static java.util.Objects.requireNonNull;
import android.app.Notification;
import android.app.Notification.MessagingStyle.Message;
import android.app.NotificationChannel;
@@ -50,6 +52,7 @@ import android.util.ArraySet;
import android.view.View;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
@@ -59,6 +62,7 @@ import com.android.internal.util.ContrastColorUtil;
import com.android.systemui.statusbar.InflationTask;
import com.android.systemui.statusbar.StatusBarIconView;
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.NotifLifetimeExtender;
@@ -105,9 +109,18 @@ public final class NotificationEntry extends ListEntry {
/** If this was a group child that was promoted to the top level, then who did the promoting. */
@Nullable NotifPromoter mNotifPromoter;
/** If this notification had an issue with inflating. Only used with the NewNotifPipeline **/
/**
* If this notification was cancelled by system server, then the reason that was supplied.
* Uncancelled notifications always have REASON_NOT_CANCELED. Note that lifetime-extended
* notifications will have this set even though they are still in the active notification set.
*/
@CancellationReason int mCancellationReason = REASON_NOT_CANCELED;
/** @see #hasInflationError() */
private boolean mHasInflationError;
/** @see #getDismissState() */
@NonNull private DismissState mDismissState = DismissState.NOT_DISMISSED;
/*
* Old members
@@ -169,9 +182,9 @@ public final class NotificationEntry extends ListEntry {
public NotificationEntry(
@NonNull StatusBarNotification sbn,
@NonNull Ranking ranking) {
super(Objects.requireNonNull(Objects.requireNonNull(sbn).getKey()));
super(requireNonNull(requireNonNull(sbn).getKey()));
Objects.requireNonNull(ranking);
requireNonNull(ranking);
mKey = sbn.getKey();
setSbn(sbn);
@@ -201,8 +214,8 @@ public final class NotificationEntry extends ListEntry {
* TODO: Make this package-private
*/
public void setSbn(@NonNull StatusBarNotification sbn) {
Objects.requireNonNull(sbn);
Objects.requireNonNull(sbn.getKey());
requireNonNull(sbn);
requireNonNull(sbn.getKey());
if (!sbn.getKey().equals(mKey)) {
throw new IllegalArgumentException("New key " + sbn.getKey()
@@ -227,8 +240,8 @@ public final class NotificationEntry extends ListEntry {
* TODO: Make this package-private
*/
public void setRanking(@NonNull Ranking ranking) {
Objects.requireNonNull(ranking);
Objects.requireNonNull(ranking.getKey());
requireNonNull(ranking);
requireNonNull(ranking.getKey());
if (!ranking.getKey().equals(mKey)) {
throw new IllegalArgumentException("New key " + ranking.getKey()
@@ -238,6 +251,34 @@ public final class NotificationEntry extends ListEntry {
mRanking = ranking;
}
/*
* Bookkeeping getters and setters
*/
/**
* Whether this notification had an error when attempting to inflate. This is only used in
* the NewNotifPipeline
*/
public boolean hasInflationError() {
return mHasInflationError;
}
void setHasInflationError(boolean hasError) {
mHasInflationError = hasError;
}
/**
* Set if the user has dismissed this notif but we haven't yet heard back from system server to
* confirm the dismissal.
*/
@NonNull public DismissState getDismissState() {
return mDismissState;
}
void setDismissState(@NonNull DismissState dismissState) {
mDismissState = requireNonNull(dismissState);
}
/*
* Convenience getters for SBN and Ranking members
*/
@@ -275,7 +316,6 @@ public final class NotificationEntry extends ListEntry {
return mRanking.canBubble();
}
public @NonNull List<Notification.Action> getSmartActions() {
return mRanking.getSmartActions();
}
@@ -578,18 +618,6 @@ public final class NotificationEntry extends ListEntry {
remoteInputTextWhenReset = null;
}
void setHasInflationError(boolean hasError) {
mHasInflationError = hasError;
}
/**
* Whether this notification had an error when attempting to inflate. This is only used in
* the NewNotifPipeline
*/
public boolean hasInflationError() {
return mHasInflationError;
}
public void setHasSentReply() {
hasSentReply = true;
}
@@ -974,6 +1002,16 @@ public final class NotificationEntry extends ListEntry {
}
}
/** @see #getDismissState() */
public enum DismissState {
/** User has not dismissed this notif or its parent */
NOT_DISMISSED,
/** User has dismissed this notif specifically */
DISMISSED,
/** User has dismissed this notif's parent (which implicitly dismisses this one as well) */
PARENT_DISMISSED,
}
private static final long LAUNCH_COOLDOWN = 2000;
private static final long REMOTE_INPUT_COOLDOWN = 500;
private static final long INITIALIZATION_DELAY = 400;

View File

@@ -0,0 +1,41 @@
/*
* 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 com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.NOT_DISMISSED;
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;
/**
* Filters out notifications that have been dismissed locally (by the user) but that system server
* hasn't yet confirmed the removal of.
*/
public class HideLocallyDismissedNotifsCoordinator implements Coordinator {
@Override
public void attach(NotifPipeline pipeline) {
pipeline.addPreGroupFilter(mFilter);
}
private final NotifFilter mFilter = new NotifFilter("HideLocallyDismissedNotifsFilter") {
@Override
public boolean shouldFilterOut(NotificationEntry entry, long now) {
return entry.getDismissState() != NOT_DISMISSED;
}
};
}

View File

@@ -56,6 +56,7 @@ public class NotifCoordinators implements Dumpable {
PreparationCoordinator preparationCoordinator) {
dumpController.registerDumpable(TAG, this);
mCoordinators.add(new HideLocallyDismissedNotifsCoordinator());
mCoordinators.add(keyguardCoordinator);
mCoordinators.add(rankingCoordinator);
mCoordinators.add(foregroundCoordinator);

View File

@@ -72,7 +72,7 @@ public class PreparationCoordinator implements Coordinator {
}
@Override
public void onEntryRemoved(NotificationEntry entry, int reason, boolean removedByUser) {
public void onEntryRemoved(NotificationEntry entry, int reason) {
abortInflation(entry, "entryRemoved reason=" + reason);
}
};

View File

@@ -54,10 +54,7 @@ public interface NotifCollectionListener {
* immediately after a user dismisses a notification: we wait until we receive confirmation from
* system server before considering the notification removed.
*/
default void onEntryRemoved(
NotificationEntry entry,
@CancellationReason int reason,
boolean removedByUser) {
default void onEntryRemoved(NotificationEntry entry, @CancellationReason int reason) {
}
/**

View File

@@ -61,6 +61,14 @@ class NotifCollectionLogger @Inject constructor(
})
}
fun logNotifDismissed(key: String) {
buffer.log(TAG, INFO, {
str1 = key
}, {
"DISMISSED $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:" })

View File

@@ -17,9 +17,16 @@
package com.android.systemui.statusbar.notification.collection;
import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CLICK;
import static android.service.notification.NotificationStats.DISMISSAL_SHADE;
import static android.service.notification.NotificationStats.DISMISS_SENTIMENT_NEUTRAL;
import static com.android.systemui.statusbar.notification.collection.NotifCollection.REASON_NOT_CANCELED;
import static com.android.systemui.statusbar.notification.collection.NotifCollection.REASON_UNKNOWN;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.DISMISSED;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.NOT_DISMISSED;
import static com.android.systemui.statusbar.notification.collection.NotificationEntry.DismissState.PARENT_DISMISSED;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -40,7 +47,6 @@ 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;
@@ -77,6 +83,7 @@ import org.mockito.Spy;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -236,7 +243,7 @@ public class NotifCollectionTest extends SysuiTestCase {
mNoMan.retractNotif(notif.sbn, REASON_APP_CANCEL);
// THEN the listener is notified
verify(mCollectionListener).onEntryRemoved(entry, REASON_APP_CANCEL, false);
verify(mCollectionListener).onEntryRemoved(entry, REASON_APP_CANCEL);
verify(mCollectionListener).onEntryCleanUp(entry);
assertEquals(notif.sbn, entry.getSbn());
assertEquals(notif.ranking, entry.getRanking());
@@ -322,24 +329,15 @@ public class NotifCollectionTest extends SysuiTestCase {
}
@Test
public void testDismissNotification() throws RemoteException {
// GIVEN a collection with a couple notifications and a lifetime extender
mCollection.addNotificationLifetimeExtender(mExtender1);
public void testDismissNotificationSentToSystemServer() throws RemoteException {
// GIVEN a collection with a couple notifications
NotifEvent notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
NotifEvent notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88, "barTag"));
NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key);
// WHEN a notification is manually dismissed
DismissedByUserStats stats = new DismissedByUserStats(
NotificationStats.DISMISSAL_SHADE,
NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
NotificationVisibility.obtain(entry2.getKey(), 7, 2, true));
mCollection.dismissNotification(entry2, REASON_CLICK, stats);
// THEN we check for lifetime extension
verify(mExtender1).shouldExtendLifetime(entry2, REASON_CLICK);
DismissedByUserStats stats = defaultStats(entry2);
mCollection.dismissNotification(entry2, defaultStats(entry2));
// THEN we send the dismissal to system server
verify(mStatusBarService).onNotificationClear(
@@ -351,9 +349,96 @@ public class NotifCollectionTest extends SysuiTestCase {
stats.dismissalSurface,
stats.dismissalSentiment,
stats.notificationVisibility);
}
// THEN we fire a remove event
verify(mCollectionListener).onEntryRemoved(entry2, REASON_CLICK, true);
@Test
public void testDismissedNotificationsAreMarkedAsDismissedLocally() {
// GIVEN a collection with a notification
NotifEvent notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN a notification is manually dismissed
mCollection.dismissNotification(entry1, defaultStats(entry1));
// THEN the entry is marked as dismissed locally
assertEquals(DISMISSED, entry1.getDismissState());
}
@Test
public void testDismissedNotificationsCannotBeLifetimeExtended() {
// GIVEN a collection with a notification and a lifetime extender
mCollection.addNotificationLifetimeExtender(mExtender1);
NotifEvent notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN a notification is manually dismissed
mCollection.dismissNotification(entry1, defaultStats(entry1));
// THEN lifetime extenders are never queried
verify(mExtender1, never()).shouldExtendLifetime(eq(entry1), anyInt());
}
@Test
public void testDismissedNotificationsDoNotTriggerRemovalEvents() {
// GIVEN a collection with a notification
NotifEvent notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN a notification is manually dismissed
mCollection.dismissNotification(entry1, defaultStats(entry1));
// THEN onEntryRemoved is not called
verify(mCollectionListener, never()).onEntryRemoved(eq(entry1), anyInt());
}
@Test
public void testDismissedNotificationsStillAppearInNotificationSet() {
// GIVEN a collection with a notification
NotifEvent notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN a notification is manually dismissed
mCollection.dismissNotification(entry1, defaultStats(entry1));
// THEN the dismissed entry still appears in the notification set
assertEquals(
new ArraySet<>(Collections.singletonList(entry1)),
new ArraySet<>(mCollection.getActiveNotifs()));
}
@Test
public void testDismissingLifetimeExtendedSummaryDoesNotDismissChildren() {
// GIVEN A notif group with one summary and two children
mCollection.addNotificationLifetimeExtender(mExtender1);
NotifEvent notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 1, "myTag")
.setGroup(mContext, GROUP_1)
.setGroupSummary(mContext, true));
NotifEvent notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 2, "myTag")
.setGroup(mContext, GROUP_1));
NotifEvent notif3 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3, "myTag")
.setGroup(mContext, GROUP_1));
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key);
NotificationEntry entry3 = mCollectionListener.getEntry(notif3.key);
// GIVEN that the summary and one child are retracted, but both are lifetime-extended
mExtender1.shouldExtendLifetime = true;
mNoMan.retractNotif(notif1.sbn, REASON_CANCEL);
mNoMan.retractNotif(notif2.sbn, REASON_CANCEL);
assertEquals(
new ArraySet<>(List.of(entry1, entry2, entry3)),
new ArraySet<>(mCollection.getActiveNotifs()));
// WHEN the summary is dismissed by the user
mCollection.dismissNotification(entry1, defaultStats(entry1));
// THEN the summary is removed, but both children stick around
assertEquals(
new ArraySet<>(List.of(entry2, entry3)),
new ArraySet<>(mCollection.getActiveNotifs()));
assertEquals(NOT_DISMISSED, entry2.getDismissState());
assertEquals(NOT_DISMISSED, entry3.getDismissState());
}
@Test(expected = IllegalStateException.class)
@@ -366,14 +451,114 @@ public class NotifCollectionTest extends SysuiTestCase {
mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN);
// WHEN we try to dismiss a notification that isn't present
mCollection.dismissNotification(
entry2,
REASON_CLICK,
new DismissedByUserStats(0, 0, NotificationVisibility.obtain("foo", 47, 3, true)));
mCollection.dismissNotification(entry2, defaultStats(entry2));
// THEN an exception is thrown
}
@Test
public void testGroupChildrenAreDismissedLocallyWhenSummaryIsDismissed() {
// GIVEN a collection with two grouped notifs in it
NotifEvent notif0 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0)
.setGroup(mContext, GROUP_1)
.setGroupSummary(mContext, true));
NotifEvent notif1 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 1)
.setGroup(mContext, GROUP_1));
NotificationEntry entry0 = mCollectionListener.getEntry(notif0.key);
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN the summary is dismissed
mCollection.dismissNotification(entry0, defaultStats(entry0));
// THEN all members of the group are marked as dismissed locally
assertEquals(DISMISSED, entry0.getDismissState());
assertEquals(PARENT_DISMISSED, entry1.getDismissState());
}
@Test
public void testUpdatingDismissedSummaryBringsChildrenBack() {
// GIVEN a collection with two grouped notifs in it
NotifEvent notif0 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0)
.setGroup(mContext, GROUP_1)
.setGroupSummary(mContext, true));
NotifEvent notif1 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 1)
.setGroup(mContext, GROUP_1));
NotificationEntry entry0 = mCollectionListener.getEntry(notif0.key);
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN the summary is dismissed but then reposted without a group
mCollection.dismissNotification(entry0, defaultStats(entry0));
NotifEvent notif0a = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0));
// THEN it and all of its previous children are no longer dismissed locally
assertEquals(NOT_DISMISSED, entry0.getDismissState());
assertEquals(NOT_DISMISSED, entry1.getDismissState());
}
@Test
public void testDismissedChildrenAreNotResetByParentUpdate() {
// GIVEN a collection with three grouped notifs in it
NotifEvent notif0 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0)
.setGroup(mContext, GROUP_1)
.setGroupSummary(mContext, true));
NotifEvent notif1 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 1)
.setGroup(mContext, GROUP_1));
NotifEvent notif2 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 2)
.setGroup(mContext, GROUP_1));
NotificationEntry entry0 = mCollectionListener.getEntry(notif0.key);
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key);
// WHEN a child is dismissed, then the parent is dismissed, then the parent is updated
mCollection.dismissNotification(entry1, defaultStats(entry1));
mCollection.dismissNotification(entry0, defaultStats(entry0));
NotifEvent notif0a = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0));
// THEN the manually-dismissed child is still marked as dismissed
assertEquals(NOT_DISMISSED, entry0.getDismissState());
assertEquals(DISMISSED, entry1.getDismissState());
assertEquals(NOT_DISMISSED, entry2.getDismissState());
}
@Test
public void testUpdatingGroupKeyOfDismissedSummaryBringsChildrenBack() {
// GIVEN a collection with two grouped notifs in it
NotifEvent notif0 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0)
.setOverrideGroupKey(GROUP_1)
.setGroupSummary(mContext, true));
NotifEvent notif1 = mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 1)
.setOverrideGroupKey(GROUP_1));
NotificationEntry entry0 = mCollectionListener.getEntry(notif0.key);
NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key);
// WHEN the summary is dismissed but then reposted AND in the same update one of the
// children's ranking loses its override group
mCollection.dismissNotification(entry0, defaultStats(entry0));
mNoMan.setRanking(entry1.getKey(), new RankingBuilder()
.setKey(entry1.getKey())
.build());
mNoMan.postNotif(
buildNotif(TEST_PACKAGE, 0)
.setOverrideGroupKey(GROUP_1)
.setGroupSummary(mContext, true));
// THEN it and all of its previous children are no longer dismissed locally, including the
// child that is no longer part of the group
assertEquals(NOT_DISMISSED, entry0.getDismissState());
assertEquals(NOT_DISMISSED, entry1.getDismissState());
}
@Test
public void testLifetimeExtendersAreQueriedWhenNotifRemoved() {
// GIVEN a couple notifications and a few lifetime extenders
@@ -389,12 +574,12 @@ public class NotifCollectionTest extends SysuiTestCase {
NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key);
// WHEN a notification is removed
mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN);
mNoMan.retractNotif(notif2.sbn, REASON_CLICK);
// THEN each extender is asked whether to extend, even if earlier ones return true
verify(mExtender1).shouldExtendLifetime(entry2, REASON_UNKNOWN);
verify(mExtender2).shouldExtendLifetime(entry2, REASON_UNKNOWN);
verify(mExtender3).shouldExtendLifetime(entry2, REASON_UNKNOWN);
verify(mExtender1).shouldExtendLifetime(entry2, REASON_CLICK);
verify(mExtender2).shouldExtendLifetime(entry2, REASON_CLICK);
verify(mExtender3).shouldExtendLifetime(entry2, REASON_CLICK);
// THEN the entry is not removed
assertTrue(mCollection.getActiveNotifs().contains(entry2));
@@ -428,9 +613,9 @@ public class NotifCollectionTest extends SysuiTestCase {
mExtender2.callback.onEndLifetimeExtension(mExtender2, entry2);
// THEN each extender is re-queried
verify(mExtender1).shouldExtendLifetime(entry2, REASON_UNKNOWN);
verify(mExtender2).shouldExtendLifetime(entry2, REASON_UNKNOWN);
verify(mExtender3).shouldExtendLifetime(entry2, REASON_UNKNOWN);
verify(mExtender1).shouldExtendLifetime(entry2, REASON_APP_CANCEL);
verify(mExtender2).shouldExtendLifetime(entry2, REASON_APP_CANCEL);
verify(mExtender3).shouldExtendLifetime(entry2, REASON_APP_CANCEL);
// THEN the entry is not removed
assertTrue(mCollection.getActiveNotifs().contains(entry2));
@@ -466,9 +651,9 @@ public class NotifCollectionTest extends SysuiTestCase {
assertTrue(mCollection.getActiveNotifs().contains(entry2));
// THEN we don't re-query the extenders
verify(mExtender1, never()).shouldExtendLifetime(eq(entry2), anyInt());
verify(mExtender2, never()).shouldExtendLifetime(eq(entry2), anyInt());
verify(mExtender3, never()).shouldExtendLifetime(eq(entry2), anyInt());
verify(mExtender1, never()).shouldExtendLifetime(entry2, REASON_APP_CANCEL);
verify(mExtender2, never()).shouldExtendLifetime(entry2, REASON_APP_CANCEL);
verify(mExtender3, never()).shouldExtendLifetime(entry2, REASON_APP_CANCEL);
// THEN the entry properly records all extenders that returned true
assertEquals(Arrays.asList(mExtender1), entry2.mLifetimeExtenders);
@@ -501,7 +686,7 @@ public class NotifCollectionTest extends SysuiTestCase {
// THEN the entry removed
assertFalse(mCollection.getActiveNotifs().contains(entry2));
verify(mCollectionListener).onEntryRemoved(entry2, REASON_UNKNOWN, false);
verify(mCollectionListener).onEntryRemoved(entry2, REASON_UNKNOWN);
}
@Test
@@ -591,6 +776,36 @@ public class NotifCollectionTest extends SysuiTestCase {
assertEquals(notif2a.ranking, entry2.getRanking());
}
@Test
public void testCancellationReasonIsSetWhenNotifIsCancelled() {
// GIVEN a notification
NotifEvent notif0 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3));
NotificationEntry entry0 = mCollectionListener.getEntry(notif0.key);
// WHEN the notification is retracted
mNoMan.retractNotif(notif0.sbn, REASON_APP_CANCEL);
// THEN the retraction reason is stored on the notif
assertEquals(REASON_APP_CANCEL, entry0.mCancellationReason);
}
@Test
public void testCancellationReasonIsClearedWhenNotifIsUpdated() {
// GIVEN a notification and a lifetime extender that will preserve it
NotifEvent notif0 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3));
NotificationEntry entry0 = mCollectionListener.getEntry(notif0.key);
mCollection.addNotificationLifetimeExtender(mExtender1);
mExtender1.shouldExtendLifetime = true;
// WHEN the notification is retracted and subsequently reposted
mNoMan.retractNotif(notif0.sbn, REASON_APP_CANCEL);
assertEquals(REASON_APP_CANCEL, entry0.mCancellationReason);
mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3));
// THEN the notification has its cancellation reason cleared
assertEquals(REASON_NOT_CANCELED, entry0.mCancellationReason);
}
private static NotificationEntryBuilder buildNotif(String pkg, int id, String tag) {
return new NotificationEntryBuilder()
.setPkg(pkg)
@@ -604,6 +819,13 @@ public class NotifCollectionTest extends SysuiTestCase {
.setId(id);
}
private static DismissedByUserStats defaultStats(NotificationEntry entry) {
return new DismissedByUserStats(
DISMISSAL_SHADE,
DISMISS_SENTIMENT_NEUTRAL,
NotificationVisibility.obtain(entry.getKey(), 7, 2, true));
}
private static class RecordingCollectionListener implements NotifCollectionListener {
private final Map<String, NotificationEntry> mLastSeenEntries = new ArrayMap<>();
@@ -621,7 +843,7 @@ public class NotifCollectionTest extends SysuiTestCase {
}
@Override
public void onEntryRemoved(NotificationEntry entry, int reason, boolean removedByUser) {
public void onEntryRemoved(NotificationEntry entry, int reason) {
}
@Override
@@ -674,4 +896,6 @@ public class NotifCollectionTest extends SysuiTestCase {
private static final String TEST_PACKAGE = "com.android.test.collection";
private static final String TEST_PACKAGE2 = "com.android.test.collection2";
private static final String GROUP_1 = "group_1";
}