Optimistically dismiss children of dismissed groups

When a group summary is dismissed, system server automatically dismisses
all of its children as well. To avoid flickering on sysui side while we
wait for these dismissals to come in, we mark the summary as well as its
children as "dismissed" and filter them all out immediately.

We don't actually remove the entry or its children from the
NotifCollection until we hear back from system server. This allows us to
stay as close as possible to system server's state and to know exactly
when we are deviating.

 - dismissNotification() no longer calls removeNotification(). Instead
   it just marks the notif as locally dismissed.
 - As a side-effect of the above, onEntryRemoved() is no longer called
   when a user dismisses a notification. It will be called later, when
   system server gets back to us and confirms the retraction. Removes
   the removedByUser parameter as a result.
 - Adds HideLocallyDismissedNotifsCoordinator, which filters out any
   notif that has been marked as locally dismissed.
 - User-dismissed (i.e. locally dismissed) notifications can no longer
   be lifetime extended. If the user wants 'em gone, they're gone.
 - removeNotification() renamed to tryRemoveNotification() and is now
   solely responsibly for managing lifetime extenders and removing a
   notif from the notif set if no lifetime extenders want to
   countermand.
 - onNotificationRemoved() takes on some of removeNotification()s old
   responsibilities, mainly around updating rankings.
 - Local dismissal and lifetime extension had some funky interactions;
   those should be worked out now.
 - As a side-effect of the above, we're now tracking the cancellation
   reason for all lifetime-extended notifs.

Test: atest
Bug: 112656837
Change-Id: I3c8bbf31af6c247b3f2de659266cb1f02011d710
This commit is contained in:
Ned Burns
2020-01-08 19:58:57 -05:00
parent 098584d234
commit 4258d70a7e
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";
}