blob: 2c85fddc2905f9ab2349d2841bfb53a054628837 [file] [log] [blame]
/*
* Copyright (C) 2019 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;
import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL_ALL;
import static android.service.notification.NotificationListenerService.REASON_ASSISTANT_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CANCEL_ALL;
import static android.service.notification.NotificationListenerService.REASON_CHANNEL_BANNED;
import static android.service.notification.NotificationListenerService.REASON_CHANNEL_REMOVED;
import static android.service.notification.NotificationListenerService.REASON_CLEAR_DATA;
import static android.service.notification.NotificationListenerService.REASON_CLICK;
import static android.service.notification.NotificationListenerService.REASON_ERROR;
import static android.service.notification.NotificationListenerService.REASON_GROUP_OPTIMIZATION;
import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED;
import static android.service.notification.NotificationListenerService.REASON_LISTENER_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_LISTENER_CANCEL_ALL;
import static android.service.notification.NotificationListenerService.REASON_PACKAGE_BANNED;
import static android.service.notification.NotificationListenerService.REASON_PACKAGE_CHANGED;
import static android.service.notification.NotificationListenerService.REASON_PACKAGE_SUSPENDED;
import static android.service.notification.NotificationListenerService.REASON_PROFILE_TURNED_OFF;
import static android.service.notification.NotificationListenerService.REASON_SNOOZED;
import static android.service.notification.NotificationListenerService.REASON_TIMEOUT;
import static android.service.notification.NotificationListenerService.REASON_UNAUTOBUNDLED;
import static android.service.notification.NotificationListenerService.REASON_USER_STOPPED;
import static com.android.systemui.statusbar.notification.NotificationUtils.logKey;
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 com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionLoggerKt.cancellationReasonDebugString;
import static java.util.Objects.requireNonNull;
import android.annotation.IntDef;
import android.annotation.MainThread;
import android.annotation.UserIdInt;
import android.app.Notification;
import android.app.NotificationChannel;
import android.os.Handler;
import android.os.RemoteException;
import android.os.Trace;
import android.os.UserHandle;
import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationListenerService.Ranking;
import android.service.notification.NotificationListenerService.RankingMap;
import android.service.notification.StatusBarNotification;
import android.util.ArrayMap;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.Dumpable;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.dump.DumpManager;
import com.android.systemui.dump.LogBufferEulogizer;
import com.android.systemui.statusbar.notification.NotifPipelineFlags;
import com.android.systemui.statusbar.notification.collection.coalescer.CoalescedEvent;
import com.android.systemui.statusbar.notification.collection.coalescer.GroupCoalescer;
import com.android.systemui.statusbar.notification.collection.coalescer.GroupCoalescer.BatchableNotificationHandler;
import com.android.systemui.statusbar.notification.collection.notifcollection.BindEntryEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.ChannelChangedEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.CleanUpEntryEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.CollectionReadyForBuildListener;
import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
import com.android.systemui.statusbar.notification.collection.notifcollection.EntryAddedEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.EntryRemovedEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.EntryUpdatedEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.InitEntryEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.InternalNotifUpdater;
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.NotifCollectionLoggerKt;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender;
import com.android.systemui.statusbar.notification.collection.notifcollection.RankingAppliedEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.RankingUpdatedEvent;
import com.android.systemui.util.Assert;
import com.android.systemui.util.time.SystemClock;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
/**
* Keeps a record of all of the "active" notifications, i.e. the notifications that are currently
* posted to the phone. This collection is unsorted, ungrouped, and unfiltered. Just because a
* notification appears in this collection doesn't mean that it's currently present in the shade
* (notifications can be hidden for a variety of reasons). Code that cares about what notifications
* are *visible* right now should register listeners later in the pipeline.
*
* Each notification is represented by a {@link NotificationEntry}, which is itself made up of two
* parts: a {@link StatusBarNotification} and a {@link Ranking}. When notifications are updated,
* their underlying SBNs and Rankings are swapped out, but the enclosing NotificationEntry (and its
* associated key) remain the same. In general, an SBN can only be updated when the notification is
* reposted by the source app; Rankings are updated much more often, usually every time there is an
* update from any kind from NotificationManager.
*
* In general, this collection closely mirrors the list maintained by NotificationManager, but it
* can occasionally diverge due to lifetime extenders (see
* {@link #addNotificationLifetimeExtender(NotifLifetimeExtender)}).
*
* Interested parties can register listeners
* ({@link #addCollectionListener(NotifCollectionListener)}) to be informed when notifications
* events occur.
*/
@MainThread
@SysUISingleton
public class NotifCollection implements Dumpable {
private final IStatusBarService mStatusBarService;
private final SystemClock mClock;
private final NotifPipelineFlags mNotifPipelineFlags;
private final NotifCollectionLogger mLogger;
private final Handler mMainHandler;
private final LogBufferEulogizer mEulogizer;
private final DumpManager mDumpManager;
private final Map<String, NotificationEntry> mNotificationSet = new ArrayMap<>();
private final Collection<NotificationEntry> mReadOnlyNotificationSet =
Collections.unmodifiableCollection(mNotificationSet.values());
private final HashMap<String, FutureDismissal> mFutureDismissals = new HashMap<>();
@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 Set<String> mNotificationsWithoutRankings = Collections.emptySet();
private Queue<NotifEvent> mEventQueue = new ArrayDeque<>();
private boolean mAttached = false;
private boolean mAmDispatchingToOtherCode;
private long mInitializedTimestamp = 0;
@Inject
public NotifCollection(
IStatusBarService statusBarService,
SystemClock clock,
NotifPipelineFlags notifPipelineFlags,
NotifCollectionLogger logger,
@Main Handler mainHandler,
LogBufferEulogizer logBufferEulogizer,
DumpManager dumpManager) {
mStatusBarService = statusBarService;
mClock = clock;
mNotifPipelineFlags = notifPipelineFlags;
mLogger = logger;
mMainHandler = mainHandler;
mEulogizer = logBufferEulogizer;
mDumpManager = dumpManager;
}
/** Initializes the NotifCollection and registers it to receive notification events. */
public void attach(GroupCoalescer groupCoalescer) {
Assert.isMainThread();
if (mAttached) {
throw new RuntimeException("attach() called twice");
}
mAttached = true;
mDumpManager.registerDumpable(TAG, this);
groupCoalescer.setNotificationHandler(mNotifHandler);
}
/**
* Sets the class responsible for converting the collection into the list of currently-visible
* notifications.
*/
void setBuildListener(CollectionReadyForBuildListener buildListener) {
Assert.isMainThread();
mBuildListener = buildListener;
}
/** @see NotifPipeline#getEntry(String) () */
@Nullable
NotificationEntry getEntry(@NonNull String key) {
return mNotificationSet.get(key);
}
/** @see NotifPipeline#getAllNotifs() */
Collection<NotificationEntry> getAllNotifs() {
Assert.isMainThread();
return mReadOnlyNotificationSet;
}
/** @see NotifPipeline#addCollectionListener(NotifCollectionListener) */
void addCollectionListener(NotifCollectionListener listener) {
Assert.isMainThread();
mNotifCollectionListeners.add(listener);
}
/** @see NotifPipeline#removeCollectionListener(NotifCollectionListener) */
void removeCollectionListener(NotifCollectionListener listener) {
Assert.isMainThread();
mNotifCollectionListeners.remove(listener);
}
/** @see NotifPipeline#addNotificationLifetimeExtender(NotifLifetimeExtender) */
void addNotificationLifetimeExtender(NotifLifetimeExtender extender) {
Assert.isMainThread();
checkForReentrantCall();
if (mLifetimeExtenders.contains(extender)) {
throw new IllegalArgumentException("Extender " + extender + " already added.");
}
mLifetimeExtenders.add(extender);
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);
}
/**
* Dismisses multiple notifications on behalf of the user.
*/
public void dismissNotifications(
List<Pair<NotificationEntry, DismissedByUserStats>> entriesToDismiss) {
Assert.isMainThread();
checkForReentrantCall();
// TODO (b/206842750): This method is called from (silent) clear all and non-clear all
// contexts and should be checking the NO_CLEAR flag, rather than depending on NSSL
// to pass in a properly filtered list of notifications
final List<NotificationEntry> entriesToLocallyDismiss = new ArrayList<>();
for (int i = 0; i < entriesToDismiss.size(); i++) {
NotificationEntry entry = entriesToDismiss.get(i).first;
DismissedByUserStats stats = entriesToDismiss.get(i).second;
requireNonNull(stats);
NotificationEntry storedEntry = mNotificationSet.get(entry.getKey());
if (storedEntry == null) {
mLogger.logNonExistentNotifDismissed(entry);
continue;
}
if (entry != storedEntry) {
throw mEulogizer.record(
new IllegalStateException("Invalid entry: "
+ "different stored and dismissed entries for " + logKey(entry)
+ " stored=@" + Integer.toHexString(storedEntry.hashCode())));
}
if (entry.getDismissState() == DISMISSED) {
continue;
}
updateDismissInterceptors(entry);
if (isDismissIntercepted(entry)) {
mLogger.logNotifDismissedIntercepted(entry);
continue;
}
entriesToLocallyDismiss.add(entry);
if (!isCanceled(entry)) {
// send message to system server if this notification hasn't already been cancelled
try {
mStatusBarService.onNotificationClear(
entry.getSbn().getPackageName(),
entry.getSbn().getUser().getIdentifier(),
entry.getSbn().getKey(),
stats.dismissalSurface,
stats.dismissalSentiment,
stats.notificationVisibility);
} catch (RemoteException e) {
// system process is dead if we're here.
mLogger.logRemoteExceptionOnNotificationClear(entry, e);
}
}
}
locallyDismissNotifications(entriesToLocallyDismiss);
dispatchEventsAndRebuildList("dismissNotifications");
}
/**
* Dismisses a single notification on behalf of the user.
*/
public void dismissNotification(
NotificationEntry entry,
@NonNull DismissedByUserStats stats) {
dismissNotifications(List.of(new Pair<>(entry, stats)));
}
/**
* Dismisses all clearable notifications for a given userid on behalf of the user.
*/
public void dismissAllNotifications(@UserIdInt int userId) {
Assert.isMainThread();
checkForReentrantCall();
mLogger.logDismissAll(userId);
try {
// TODO(b/169585328): Do not clear media player notifications
mStatusBarService.onClearAllNotifications(userId);
} catch (RemoteException e) {
// system process is dead if we're here.
mLogger.logRemoteExceptionOnClearAllNotifications(e);
}
final List<NotificationEntry> entries = new ArrayList<>(getAllNotifs());
for (int i = entries.size() - 1; i >= 0; i--) {
NotificationEntry entry = entries.get(i);
if (!shouldDismissOnClearAll(entry, userId)) {
// system server won't be removing these notifications, but we still give dismiss
// interceptors the chance to filter the notification
updateDismissInterceptors(entry);
if (isDismissIntercepted(entry)) {
mLogger.logNotifClearAllDismissalIntercepted(entry);
}
entries.remove(i);
}
}
locallyDismissNotifications(entries);
dispatchEventsAndRebuildList("dismissAllNotifications");
}
/**
* Optimistically marks the given notifications as dismissed -- we'll wait for the signal
* from system server before removing it from our notification set.
*/
private void locallyDismissNotifications(List<NotificationEntry> entries) {
final List<NotificationEntry> canceledEntries = new ArrayList<>();
for (int i = 0; i < entries.size(); i++) {
NotificationEntry entry = entries.get(i);
entry.setDismissState(DISMISSED);
mLogger.logNotifDismissed(entry);
if (isCanceled(entry)) {
canceledEntries.add(entry);
} else {
// 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 (shouldAutoDismissChildren(otherEntry, entry.getSbn().getGroupKey())) {
otherEntry.setDismissState(PARENT_DISMISSED);
mLogger.logChildDismissed(otherEntry);
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) {
mLogger.logDismissOnAlreadyCanceledEntry(canceledEntry);
tryRemoveNotification(canceledEntry);
}
}
private void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
Assert.isMainThread();
postNotification(sbn, requireRanking(rankingMap, sbn.getKey()));
applyRanking(rankingMap);
dispatchEventsAndRebuildList("onNotificationPosted");
}
private void onNotificationGroupPosted(List<CoalescedEvent> batch) {
Assert.isMainThread();
mLogger.logNotifGroupPosted(batch.get(0).getSbn().getGroupKey(), batch.size());
for (CoalescedEvent event : batch) {
postNotification(event.getSbn(), event.getRanking());
}
dispatchEventsAndRebuildList("onNotificationGroupPosted");
}
private void onNotificationRemoved(
StatusBarNotification sbn,
RankingMap rankingMap,
int reason) {
Assert.isMainThread();
mLogger.logNotifRemoved(sbn, reason);
final NotificationEntry entry = mNotificationSet.get(sbn.getKey());
if (entry == null) {
// TODO (b/160008901): Throw an exception here
mLogger.logNoNotificationToRemoveWithKey(sbn, reason);
return;
}
entry.mCancellationReason = reason;
tryRemoveNotification(entry);
applyRanking(rankingMap);
dispatchEventsAndRebuildList("onNotificationRemoved");
}
private void onNotificationRankingUpdate(RankingMap rankingMap) {
Assert.isMainThread();
mEventQueue.add(new RankingUpdatedEvent(rankingMap));
applyRanking(rankingMap);
dispatchEventsAndRebuildList("onNotificationRankingUpdate");
}
private void onNotificationChannelModified(
String pkgName,
UserHandle user,
NotificationChannel channel,
int modificationType) {
Assert.isMainThread();
mEventQueue.add(new ChannelChangedEvent(pkgName, user, channel, modificationType));
dispatchEventsAndRebuildList("onNotificationChannelModified");
}
private void onNotificationsInitialized() {
mInitializedTimestamp = mClock.uptimeMillis();
}
private void postNotification(
StatusBarNotification sbn,
Ranking ranking) {
NotificationEntry entry = mNotificationSet.get(sbn.getKey());
if (entry == null) {
// A new notification!
entry = new NotificationEntry(sbn, ranking, mClock.uptimeMillis());
mEventQueue.add(new InitEntryEvent(entry));
mEventQueue.add(new BindEntryEvent(entry, sbn));
mNotificationSet.put(sbn.getKey(), entry);
mLogger.logNotifPosted(entry);
mEventQueue.add(new EntryAddedEvent(entry));
} else {
// Update to an existing entry
// Notification is updated so it is essentially re-added and thus alive again, so we
// can reset its state.
// TODO: If a coalesced event ever gets here, it's possible to lose track of children,
// since their rankings might have been updated earlier (and thus we may no longer
// think a child is associated with this locally-dismissed entry).
cancelLocalDismissal(entry);
cancelLifetimeExtension(entry);
cancelDismissInterception(entry);
entry.mCancellationReason = REASON_NOT_CANCELED;
entry.setSbn(sbn);
mEventQueue.add(new BindEntryEvent(entry, sbn));
mLogger.logNotifUpdated(entry);
mEventQueue.add(new EntryUpdatedEvent(entry, true /* fromSystem */));
}
}
/**
* 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 mEulogizer.record(
new IllegalStateException("No notification to remove with key "
+ logKey(entry)));
}
if (!isCanceled(entry)) {
throw mEulogizer.record(
new IllegalStateException("Cannot remove notification " + logKey(entry)
+ ": has not been marked for removal"));
}
if (cannotBeLifetimeExtended(entry)) {
cancelLifetimeExtension(entry);
} else {
updateLifetimeExtension(entry);
}
if (!isLifetimeExtended(entry)) {
mLogger.logNotifReleased(entry);
mNotificationSet.remove(entry.getKey());
cancelDismissInterception(entry);
mEventQueue.add(new EntryRemovedEvent(entry, entry.mCancellationReason));
mEventQueue.add(new CleanUpEntryEvent(entry));
handleFutureDismissal(entry);
return true;
} else {
return false;
}
}
/**
* Get the group summary entry
* @param groupKey
* @return
*/
@Nullable
public NotificationEntry getGroupSummary(String groupKey) {
return mNotificationSet
.values()
.stream()
.filter(it -> Objects.equals(it.getSbn().getGroupKey(), groupKey))
.filter(it -> it.getSbn().getNotification().isGroupSummary())
.findFirst().orElse(null);
}
/**
* Checks if the entry is the only child in the logical group;
* it need not have a summary to qualify
*
* @param entry the entry to check
*/
public boolean isOnlyChildInGroup(NotificationEntry entry) {
String groupKey = entry.getSbn().getGroupKey();
return mNotificationSet.get(entry.getKey()) == entry
&& mNotificationSet
.values()
.stream()
.filter(it -> Objects.equals(it.getSbn().getGroupKey(), groupKey))
.filter(it -> !it.getSbn().getNotification().isGroupSummary())
.count() == 1;
}
private void applyRanking(@NonNull RankingMap rankingMap) {
ArrayMap<String, NotificationEntry> currentEntriesWithoutRankings = null;
for (NotificationEntry entry : mNotificationSet.values()) {
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
// that means this might occur when SystemUI is starting up.
Ranking ranking = new Ranking();
if (rankingMap.getRanking(entry.getKey(), ranking)) {
entry.setRanking(ranking);
// TODO: (b/145659174) update the sbn's overrideGroupKey in
// NotificationEntry.setRanking instead of here once we fully migrate to the
// NewNotifPipeline
final String newOverrideGroupKey = ranking.getOverrideGroupKey();
if (!Objects.equals(entry.getSbn().getOverrideGroupKey(),
newOverrideGroupKey)) {
entry.getSbn().setOverrideGroupKey(newOverrideGroupKey);
}
} else {
if (currentEntriesWithoutRankings == null) {
currentEntriesWithoutRankings = new ArrayMap<>();
}
currentEntriesWithoutRankings.put(entry.getKey(), entry);
}
}
}
NotifCollectionLoggerKt.maybeLogInconsistentRankings(
mLogger,
mNotificationsWithoutRankings,
currentEntriesWithoutRankings,
rankingMap
);
mNotificationsWithoutRankings = currentEntriesWithoutRankings == null
? Collections.emptySet() : currentEntriesWithoutRankings.keySet();
if (currentEntriesWithoutRankings != null && mNotifPipelineFlags.removeUnrankedNotifs()) {
for (NotificationEntry entry : currentEntriesWithoutRankings.values()) {
entry.mCancellationReason = REASON_UNKNOWN;
tryRemoveNotification(entry);
}
}
mEventQueue.add(new RankingAppliedEvent());
}
private void dispatchEventsAndRebuildList(String reason) {
Trace.beginSection("NotifCollection.dispatchEventsAndRebuildList");
mAmDispatchingToOtherCode = true;
while (!mEventQueue.isEmpty()) {
mEventQueue.remove().dispatchTo(mNotifCollectionListeners);
}
mAmDispatchingToOtherCode = false;
if (mBuildListener != null) {
mBuildListener.onBuildList(mReadOnlyNotificationSet, reason);
}
Trace.endSection();
}
private void onEndLifetimeExtension(
@NonNull NotifLifetimeExtender extender,
@NonNull NotificationEntry entry) {
Assert.isMainThread();
if (!mAttached) {
return;
}
checkForReentrantCall();
NotificationEntry collectionEntry = getEntry(entry.getKey());
String logKey = logKey(entry);
String collectionEntryIs = collectionEntry == null ? "null"
: entry == collectionEntry ? "same" : "different";
if (entry != collectionEntry) {
// TODO: We should probably make this throw, but that's too risky right now
mLogger.logEntryBeingExtendedNotInCollection(entry, extender, collectionEntryIs);
}
if (!entry.mLifetimeExtenders.remove(extender)) {
throw mEulogizer.record(new IllegalStateException(
String.format("Cannot end lifetime extension for extender \"%s\""
+ " of entry %s (collection entry is %s)",
extender.getName(), logKey, collectionEntryIs)));
}
mLogger.logLifetimeExtensionEnded(entry, extender, entry.mLifetimeExtenders.size());
if (!isLifetimeExtended(entry)) {
if (tryRemoveNotification(entry)) {
dispatchEventsAndRebuildList("onEndLifetimeExtension");
}
}
}
private void cancelLifetimeExtension(NotificationEntry entry) {
mAmDispatchingToOtherCode = true;
for (NotifLifetimeExtender extender : entry.mLifetimeExtenders) {
extender.cancelLifetimeExtension(entry);
}
mAmDispatchingToOtherCode = false;
entry.mLifetimeExtenders.clear();
}
private boolean isLifetimeExtended(NotificationEntry entry) {
return entry.mLifetimeExtenders.size() > 0;
}
private void updateLifetimeExtension(NotificationEntry entry) {
entry.mLifetimeExtenders.clear();
mAmDispatchingToOtherCode = true;
for (NotifLifetimeExtender extender : mLifetimeExtenders) {
if (extender.maybeExtendLifetime(entry, entry.mCancellationReason)) {
mLogger.logLifetimeExtended(entry, extender);
entry.mLifetimeExtenders.add(extender);
}
}
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 (entry.getDismissState() != NOT_DISMISSED) {
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 onEndDismissInterception(
NotifDismissInterceptor interceptor,
NotificationEntry entry,
@NonNull DismissedByUserStats stats) {
Assert.isMainThread();
if (!mAttached) {
return;
}
checkForReentrantCall();
if (!entry.mDismissInterceptors.remove(interceptor)) {
throw mEulogizer.record(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 mEulogizer.record(new IllegalStateException("Reentrant call detected"));
}
}
// While the NotificationListener is connecting to NotificationManager, there is a short period
// during which it's possible for us to receive events about notifications we don't yet know
// about (or that otherwise don't make sense). Until that race condition is fixed, we create a
// "forgiveness window" of five seconds during which we won't crash if we receive nonsensical
// messages from system server.
private void crashIfNotInitializing(RuntimeException exception) {
final boolean isRecentlyInitialized = mInitializedTimestamp == 0
|| mClock.uptimeMillis() - mInitializedTimestamp
< INITIALIZATION_FORGIVENESS_WINDOW;
if (isRecentlyInitialized) {
mLogger.logIgnoredError(exception.getMessage());
} else {
throw mEulogizer.record(exception);
}
}
private static Ranking requireRanking(RankingMap rankingMap, String key) {
// TODO: Modify RankingMap so that we don't have to make a copy here
Ranking ranking = new Ranking();
if (!rankingMap.getRanking(key, ranking)) {
throw new IllegalArgumentException("Ranking map doesn't contain key: " + key);
}
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 boolean isCanceled(NotificationEntry entry) {
return entry.mCancellationReason != REASON_NOT_CANCELED;
}
private boolean cannotBeLifetimeExtended(NotificationEntry entry) {
final boolean locallyDismissedByUser = entry.getDismissState() != NOT_DISMISSED;
final boolean systemServerReportedUserCancel =
entry.mCancellationReason == REASON_CLICK
|| entry.mCancellationReason == REASON_CANCEL;
return locallyDismissedByUser || systemServerReportedUserCancel;
}
/**
* When a group summary is dismissed, NotificationManager will also try to dismiss its children.
* Returns true if we think dismissing the group summary with group key
* <code>dismissedGroupKey</code> will cause NotificationManager to also dismiss
* <code>entry</code>.
*
* See NotificationManager.cancelGroupChildrenByListLocked() for corresponding code.
*/
@VisibleForTesting
static boolean shouldAutoDismissChildren(
NotificationEntry entry,
String dismissedGroupKey) {
return entry.getSbn().getGroupKey().equals(dismissedGroupKey)
&& !entry.getSbn().getNotification().isGroupSummary()
&& !hasFlag(entry, Notification.FLAG_ONGOING_EVENT)
&& !hasFlag(entry, Notification.FLAG_BUBBLE)
&& !hasFlag(entry, Notification.FLAG_NO_CLEAR)
&& entry.getDismissState() != DISMISSED;
}
/**
* When the user 'clears all notifications' through SystemUI, NotificationManager will not
* dismiss unclearable notifications.
* @return true if we think NotificationManager will dismiss the entry when asked to
* cancel this notification with {@link NotificationListenerService#REASON_CANCEL_ALL}
*
* See NotificationManager.cancelAllLocked for corresponding code.
*/
private static boolean shouldDismissOnClearAll(
NotificationEntry entry,
@UserIdInt int userId) {
return userIdMatches(entry, userId)
&& entry.isClearable()
&& !hasFlag(entry, Notification.FLAG_BUBBLE)
&& entry.getDismissState() != DISMISSED;
}
private static boolean hasFlag(NotificationEntry entry, int flag) {
return (entry.getSbn().getNotification().flags & flag) != 0;
}
/**
* Determine whether the userId applies to the notification in question, either because
* they match exactly, or one of them is USER_ALL (which is treated as a wildcard).
*
* See NotificationManager#notificationMatchesUserId
*/
private static boolean userIdMatches(NotificationEntry entry, int userId) {
return userId == UserHandle.USER_ALL
|| entry.getSbn().getUser().getIdentifier() == UserHandle.USER_ALL
|| entry.getSbn().getUser().getIdentifier() == userId;
}
@Override
public void dump(PrintWriter pw, @NonNull String[] args) {
final List<NotificationEntry> entries = new ArrayList<>(getAllNotifs());
entries.sort(Comparator.comparing(NotificationEntry::getKey));
pw.println("\t" + TAG + " unsorted/unfiltered notifications: " + entries.size());
pw.println(
ListDumper.dumpList(
entries,
true,
"\t\t"));
pw.println("\n\tmNotificationsWithoutRankings: " + mNotificationsWithoutRankings.size());
for (String key : mNotificationsWithoutRankings) {
pw.println("\t * : " + key);
}
}
private final BatchableNotificationHandler mNotifHandler = new BatchableNotificationHandler() {
@Override
public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
NotifCollection.this.onNotificationPosted(sbn, rankingMap);
}
@Override
public void onNotificationBatchPosted(List<CoalescedEvent> events) {
NotifCollection.this.onNotificationGroupPosted(events);
}
@Override
public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap) {
NotifCollection.this.onNotificationRemoved(sbn, rankingMap, REASON_UNKNOWN);
}
@Override
public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
int reason) {
NotifCollection.this.onNotificationRemoved(sbn, rankingMap, reason);
}
@Override
public void onNotificationRankingUpdate(RankingMap rankingMap) {
NotifCollection.this.onNotificationRankingUpdate(rankingMap);
}
@Override
public void onNotificationChannelModified(
String pkgName,
UserHandle user,
NotificationChannel channel,
int modificationType) {
NotifCollection.this.onNotificationChannelModified(
pkgName,
user,
channel,
modificationType);
}
@Override
public void onNotificationsInitialized() {
NotifCollection.this.onNotificationsInitialized();
}
};
private static final String TAG = "NotifCollection";
/**
* Get an object which can be used to update a notification (internally to the pipeline)
* in response to a user action.
*
* @param name the name of the component that will update notifiations
* @return an updater
*/
public InternalNotifUpdater getInternalNotifUpdater(String name) {
return (sbn, reason) -> mMainHandler.post(
() -> updateNotificationInternally(sbn, name, reason));
}
/**
* Provide an updated StatusBarNotification for an existing entry. If no entry exists for the
* given notification key, this method does nothing.
*
* @param sbn the updated notification
* @param name the component which is updating the notification
* @param reason the reason the notification is being updated
*/
private void updateNotificationInternally(StatusBarNotification sbn, String name,
String reason) {
Assert.isMainThread();
checkForReentrantCall();
// Make sure we have the notification to update
NotificationEntry entry = mNotificationSet.get(sbn.getKey());
if (entry == null) {
mLogger.logNotifInternalUpdateFailed(sbn, name, reason);
return;
}
mLogger.logNotifInternalUpdate(entry, name, reason);
// First do the pieces of postNotification which are not about assuming the notification
// was sent by the app
entry.setSbn(sbn);
mEventQueue.add(new BindEntryEvent(entry, sbn));
mLogger.logNotifUpdated(entry);
mEventQueue.add(new EntryUpdatedEvent(entry, false /* fromSystem */));
// Skip the applyRanking step and go straight to dispatching the events
dispatchEventsAndRebuildList("updateNotificationInternally");
}
/**
* A method to alert the collection that an async operation is happening, at the end of which a
* dismissal request will be made. This method has the additional guarantee that if a parent
* notification exists for a single child, then that notification will also be dismissed.
*
* The runnable returned must be run at the end of the async operation to enact the cancellation
*
* @param entry the notification we want to dismiss
* @param cancellationReason the reason for the cancellation
* @param statsCreator the callback for generating the stats for an entry
* @return the runnable to be run when the dismissal is ready to happen
*/
public Runnable registerFutureDismissal(NotificationEntry entry, int cancellationReason,
DismissedByUserStatsCreator statsCreator) {
FutureDismissal dismissal = mFutureDismissals.get(entry.getKey());
if (dismissal != null) {
mLogger.logFutureDismissalReused(dismissal);
return dismissal;
}
dismissal = new FutureDismissal(entry, cancellationReason, statsCreator);
mFutureDismissals.put(entry.getKey(), dismissal);
mLogger.logFutureDismissalRegistered(dismissal);
return dismissal;
}
private void handleFutureDismissal(NotificationEntry entry) {
final FutureDismissal futureDismissal = mFutureDismissals.remove(entry.getKey());
if (futureDismissal != null) {
futureDismissal.onSystemServerCancel(entry.mCancellationReason);
}
}
/** A single method interface that callers can pass in when registering future dismissals */
public interface DismissedByUserStatsCreator {
DismissedByUserStats createDismissedByUserStats(NotificationEntry entry);
}
/** A class which tracks the double dismissal events coming in from both the system server and
* the ui */
public class FutureDismissal implements Runnable {
private final NotificationEntry mEntry;
private final DismissedByUserStatsCreator mStatsCreator;
@Nullable
private final NotificationEntry mSummaryToDismiss;
private final String mLabel;
private boolean mDidRun;
private boolean mDidSystemServerCancel;
private FutureDismissal(NotificationEntry entry, @CancellationReason int cancellationReason,
DismissedByUserStatsCreator statsCreator) {
mEntry = entry;
mStatsCreator = statsCreator;
mSummaryToDismiss = fetchSummaryToDismiss(entry);
mLabel = "<FutureDismissal@" + Integer.toHexString(hashCode())
+ " entry=" + logKey(mEntry)
+ " reason=" + cancellationReasonDebugString(cancellationReason)
+ " summary=" + logKey(mSummaryToDismiss)
+ ">";
}
@Nullable
private NotificationEntry fetchSummaryToDismiss(NotificationEntry entry) {
if (isOnlyChildInGroup(entry)) {
String group = entry.getSbn().getGroupKey();
NotificationEntry summary = getGroupSummary(group);
if (summary != null && summary.isDismissable()) return summary;
}
return null;
}
/** called when the entry has been removed from the collection */
public void onSystemServerCancel(@CancellationReason int cancellationReason) {
Assert.isMainThread();
if (mDidSystemServerCancel) {
mLogger.logFutureDismissalDoubleCancelledByServer(this);
return;
}
mLogger.logFutureDismissalGotSystemServerCancel(this, cancellationReason);
mDidSystemServerCancel = true;
// TODO: Internally dismiss the summary now instead of waiting for onUiCancel
}
private void onUiCancel() {
mFutureDismissals.remove(mEntry.getKey());
final NotificationEntry currentEntry = getEntry(mEntry.getKey());
// generate stats for the entry before dismissing summary, which could affect state
final DismissedByUserStats stats = mStatsCreator.createDismissedByUserStats(mEntry);
// dismiss the summary (if it exists)
if (mSummaryToDismiss != null) {
final NotificationEntry currentSummary = getEntry(mSummaryToDismiss.getKey());
if (currentSummary == mSummaryToDismiss) {
mLogger.logFutureDismissalDismissing(this, "summary");
dismissNotification(mSummaryToDismiss,
mStatsCreator.createDismissedByUserStats(mSummaryToDismiss));
} else {
mLogger.logFutureDismissalMismatchedEntry(this, "summary", currentSummary);
}
}
// dismiss this entry (if it is still around)
if (mDidSystemServerCancel) {
mLogger.logFutureDismissalAlreadyCancelledByServer(this);
} else if (currentEntry == mEntry) {
mLogger.logFutureDismissalDismissing(this, "entry");
dismissNotification(mEntry, stats);
} else {
mLogger.logFutureDismissalMismatchedEntry(this, "entry", currentEntry);
}
}
/** called when the dismissal should be completed */
@Override
public void run() {
Assert.isMainThread();
if (mDidRun) {
mLogger.logFutureDismissalDoubleRun(this);
return;
}
mDidRun = true;
onUiCancel();
}
/** provides a debug label for this instance */
public String getLabel() {
return mLabel;
}
}
@IntDef(prefix = { "REASON_" }, value = {
REASON_NOT_CANCELED,
REASON_UNKNOWN,
REASON_CLICK,
REASON_CANCEL,
REASON_CANCEL_ALL,
REASON_ERROR,
REASON_PACKAGE_CHANGED,
REASON_USER_STOPPED,
REASON_PACKAGE_BANNED,
REASON_APP_CANCEL,
REASON_APP_CANCEL_ALL,
REASON_LISTENER_CANCEL,
REASON_LISTENER_CANCEL_ALL,
REASON_GROUP_SUMMARY_CANCELED,
REASON_GROUP_OPTIMIZATION,
REASON_PACKAGE_SUSPENDED,
REASON_PROFILE_TURNED_OFF,
REASON_UNAUTOBUNDLED,
REASON_CHANNEL_BANNED,
REASON_SNOOZED,
REASON_TIMEOUT,
REASON_CHANNEL_REMOVED,
REASON_CLEAR_DATA,
REASON_ASSISTANT_CANCEL,
})
@Retention(RetentionPolicy.SOURCE)
public @interface CancellationReason {}
static final int REASON_NOT_CANCELED = -1;
public static final int REASON_UNKNOWN = 0;
private static final long INITIALIZATION_FORGIVENESS_WINDOW = TimeUnit.SECONDS.toMillis(5);
}