Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 4a206a41 authored by Jeff DeCew's avatar Jeff DeCew
Browse files

Fix NotificationEntry mismatch crashes when tapping notification.

The root of these problems is holding onto a NotificationEntry instance and performing a dismiss or end to lifetime extension after a delay, during which apps or the system may cancel and re-post the given notification.  The original fix to this by lifetime extending these notifications worked in the case where this closure around a NotificationEntry perfectly matched lifetime extension, but this was becoming a whack-a-mole of finding all the ways we might need to extend these notifications.  Moreover, there was a bug in the implementation that used notification keys as the keys for the lifetime extension map, rather than NotificationEntry instances themselves, which caused b/227254780.

This CL introduces the concept of reporting future dismissals to the NotifCollection (via OnUserInteractionCallback) which allows the NotifCollection to respond to system server's notification removal events by preventing the eventual dismissal call from crashing.  This design is not the ideal end-game (see b/232260346) but rather preserves the existing call order into NotifCollection while also bailing out before any calls are made that would cause a crash.

Test: atest ExpandableNotificationRowTest StatusBarNotificationActivityStarterTest NotifCollectionTest
Fixes: 230540148
Fixes: 227254780
Bug: 232260346
Change-Id: Ia66ae5ade3fbfdd0436d54dcbeef720618622716
parent 8b636131
Loading
Loading
Loading
Loading
+149 −8
Original line number Diff line number Diff line
@@ -18,9 +18,12 @@ 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;
@@ -36,9 +39,11 @@ 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.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;

@@ -99,6 +104,7 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -143,6 +149,7 @@ public class NotifCollection implements Dumpable {
    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<>();
@@ -511,6 +518,7 @@ public class NotifCollection implements Dumpable {
            cancelDismissInterception(entry);
            mEventQueue.add(new EntryRemovedEvent(entry, entry.mCancellationReason));
            mEventQueue.add(new CleanUpEntryEvent(entry));
            handleFutureDismissal(entry);
            return true;
        } else {
            return false;
@@ -519,31 +527,32 @@ public class NotifCollection implements Dumpable {

    /**
     * Get the group summary entry
     * @param group
     * @param groupKey
     * @return
     */
    @Nullable
    public NotificationEntry getGroupSummary(String group) {
    public NotificationEntry getGroupSummary(String groupKey) {
        return mNotificationSet
                .values()
                .stream()
                .filter(it -> Objects.equals(it.getSbn().getGroup(), group))
                .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
     * @param entry
     * @return
     * 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 group = entry.getSbn().getGroup();
        String groupKey = entry.getSbn().getGroupKey();
        return mNotificationSet.get(entry.getKey()) == entry
                && mNotificationSet
                .values()
                .stream()
                .filter(it -> Objects.equals(it.getSbn().getGroup(), group))
                .filter(it -> Objects.equals(it.getSbn().getGroupKey(), groupKey))
                .filter(it -> !it.getSbn().getNotification().isGroupSummary())
                .count() == 1;
    }
@@ -916,10 +925,139 @@ public class NotifCollection implements Dumpable {
        dispatchEventsAndRebuildList();
    }

    /**
     * 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,
@@ -937,6 +1075,9 @@ public class NotifCollection implements Dumpable {
            REASON_CHANNEL_BANNED,
            REASON_SNOOZED,
            REASON_TIMEOUT,
            REASON_CHANNEL_REMOVED,
            REASON_CLEAR_DATA,
            REASON_ASSISTANT_CANCEL,
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface CancellationReason {}
+1 −1
Original line number Diff line number Diff line
@@ -93,7 +93,7 @@ class NotifCoordinatorsImpl @Inject constructor(
        mCoordinators.add(shadeEventCoordinator)
        mCoordinators.add(viewConfigCoordinator)
        mCoordinators.add(visualStabilityCoordinator)
        mCoordinators.add(activityLaunchAnimCoordinator)
//        mCoordinators.add(activityLaunchAnimCoordinator) // NOTE: will delete in followup CL
        if (notifPipelineFlags.isSmartspaceDedupingEnabled()) {
            mCoordinators.add(smartspaceDedupingCoordinator)
        }
+15 −44
Original line number Diff line number Diff line
@@ -18,17 +18,17 @@ package com.android.systemui.statusbar.notification.collection.inflation;

import static android.service.notification.NotificationStats.DISMISS_SENTIMENT_NEUTRAL;

import android.annotation.Nullable;
import android.os.SystemClock;
import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationStats;

import androidx.annotation.NonNull;

import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.notification.collection.NotifCollection;
import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.collection.coordinator.VisualStabilityCoordinator;
import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager;
import com.android.systemui.statusbar.notification.collection.render.NotificationVisibilityProvider;
import com.android.systemui.statusbar.notification.row.OnUserInteractionCallback;
import com.android.systemui.statusbar.policy.HeadsUpManager;
@@ -43,54 +43,33 @@ public class OnUserInteractionCallbackImpl implements OnUserInteractionCallback
    private final HeadsUpManager mHeadsUpManager;
    private final StatusBarStateController mStatusBarStateController;
    private final VisualStabilityCoordinator mVisualStabilityCoordinator;
    private final GroupMembershipManager mGroupMembershipManager;

    public OnUserInteractionCallbackImpl(
            NotificationVisibilityProvider visibilityProvider,
            NotifCollection notifCollection,
            HeadsUpManager headsUpManager,
            StatusBarStateController statusBarStateController,
            VisualStabilityCoordinator visualStabilityCoordinator,
            GroupMembershipManager groupMembershipManager
            VisualStabilityCoordinator visualStabilityCoordinator
    ) {
        mVisibilityProvider = visibilityProvider;
        mNotifCollection = notifCollection;
        mHeadsUpManager = headsUpManager;
        mStatusBarStateController = statusBarStateController;
        mVisualStabilityCoordinator = visualStabilityCoordinator;
        mGroupMembershipManager = groupMembershipManager;
    }

    /**
     * Callback triggered when a user:
     * 1. Manually dismisses a notification {@see ExpandableNotificationRow}.
     * 2. Clicks on a notification with flag {@link android.app.Notification#FLAG_AUTO_CANCEL}.
     * {@see StatusBarNotificationActivityStarter}
     */
    @Override
    public void onDismiss(
            NotificationEntry entry,
            @NotificationListenerService.NotificationCancelReason int cancellationReason,
            @Nullable NotificationEntry groupSummaryToDismiss
    ) {
    @NonNull
    private DismissedByUserStats getDismissedByUserStats(NotificationEntry entry) {
        int dismissalSurface = NotificationStats.DISMISSAL_SHADE;
        if (mHeadsUpManager.isAlerting(entry.getKey())) {
            dismissalSurface = NotificationStats.DISMISSAL_PEEK;
        } else if (mStatusBarStateController.isDozing()) {
            dismissalSurface = NotificationStats.DISMISSAL_AOD;
        }

        if (groupSummaryToDismiss != null) {
            onDismiss(groupSummaryToDismiss, cancellationReason, null);
        }

        mNotifCollection.dismissNotification(
                entry,
                new DismissedByUserStats(
        return new DismissedByUserStats(
                dismissalSurface,
                DISMISS_SENTIMENT_NEUTRAL,
                    mVisibilityProvider.obtain(entry, true))
        );
                mVisibilityProvider.obtain(entry, true));
    }

    @Override
@@ -100,19 +79,11 @@ public class OnUserInteractionCallbackImpl implements OnUserInteractionCallback
                SystemClock.uptimeMillis());
    }

    /**
     * @param entry that is being dismissed
     * @return the group summary to dismiss along with this entry if this is the last entry in
     * the group. Else, returns null.
     */
    @NonNull
    @Override
    @Nullable
    public NotificationEntry getGroupSummaryToDismiss(NotificationEntry entry) {
        String group = entry.getSbn().getGroup();
        if (mNotifCollection.isOnlyChildInGroup(entry)) {
            NotificationEntry summary = mNotifCollection.getGroupSummary(group);
            if (summary != null && summary.isDismissable()) return summary;
        }
        return null;
    public Runnable registerFutureDismissal(@NonNull NotificationEntry entry,
            @CancellationReason int cancellationReason) {
        return mNotifCollection.registerFutureDismissal(
                entry, cancellationReason, this::getDismissedByUserStats);
    }
}
+14 −5
Original line number Diff line number Diff line
@@ -18,12 +18,15 @@ package com.android.systemui.statusbar.notification.collection.legacy;

import static android.service.notification.NotificationStats.DISMISS_SENTIMENT_NEUTRAL;

import android.annotation.Nullable;
import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationStats;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.notification.NotificationEntryManager;
import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager;
@@ -68,8 +71,7 @@ public class OnUserInteractionCallbackImplLegacy implements OnUserInteractionCal
     *                              along with this dismissal. If null, does not additionally
     *                              dismiss any notifications.
     */
    @Override
    public void onDismiss(
    private void onDismiss(
            NotificationEntry entry,
            @NotificationListenerService.NotificationCancelReason int cancellationReason,
            @Nullable NotificationEntry groupSummaryToDismiss
@@ -106,14 +108,21 @@ public class OnUserInteractionCallbackImplLegacy implements OnUserInteractionCal
     * @return the group summary to dismiss along with this entry if this is the last entry in
     * the group. Else, returns null.
     */
    @Override
    @Nullable
    public NotificationEntry getGroupSummaryToDismiss(NotificationEntry entry) {
    private NotificationEntry getGroupSummaryToDismiss(NotificationEntry entry) {
        if (mGroupMembershipManager.isOnlyChildInGroup(entry)) {
            NotificationEntry groupSummary = mGroupMembershipManager.getLogicalGroupSummary(entry);
            return groupSummary.isDismissable() ? groupSummary : null;
        }
        return null;
    }

    @Override
    @NonNull
    public Runnable registerFutureDismissal(@NonNull NotificationEntry entry,
            @CancellationReason int cancellationReason) {
        NotificationEntry groupSummaryToDismiss = getGroupSummaryToDismiss(entry);
        return () -> onDismiss(entry, cancellationReason, groupSummaryToDismiss);
    }
}
+81 −0
Original line number Diff line number Diff line
@@ -28,7 +28,9 @@ import com.android.systemui.log.LogLevel.WTF
import com.android.systemui.log.dagger.NotificationLog
import com.android.systemui.statusbar.notification.collection.NotifCollection
import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason
import com.android.systemui.statusbar.notification.collection.NotifCollection.FutureDismissal
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.logKey
import javax.inject.Inject

fun cancellationReasonDebugString(@CancellationReason reason: Int) =
@@ -36,6 +38,7 @@ fun cancellationReasonDebugString(@CancellationReason reason: Int) =
        -1 -> "REASON_NOT_CANCELED" // NotifCollection.REASON_NOT_CANCELED
        NotifCollection.REASON_UNKNOWN -> "REASON_UNKNOWN"
        NotificationListenerService.REASON_CLICK -> "REASON_CLICK"
        NotificationListenerService.REASON_CANCEL -> "REASON_CANCEL"
        NotificationListenerService.REASON_CANCEL_ALL -> "REASON_CANCEL_ALL"
        NotificationListenerService.REASON_ERROR -> "REASON_ERROR"
        NotificationListenerService.REASON_PACKAGE_CHANGED -> "REASON_PACKAGE_CHANGED"
@@ -53,6 +56,9 @@ fun cancellationReasonDebugString(@CancellationReason reason: Int) =
        NotificationListenerService.REASON_CHANNEL_BANNED -> "REASON_CHANNEL_BANNED"
        NotificationListenerService.REASON_SNOOZED -> "REASON_SNOOZED"
        NotificationListenerService.REASON_TIMEOUT -> "REASON_TIMEOUT"
        NotificationListenerService.REASON_CHANNEL_REMOVED -> "REASON_CHANNEL_REMOVED"
        NotificationListenerService.REASON_CLEAR_DATA -> "REASON_CLEAR_DATA"
        NotificationListenerService.REASON_ASSISTANT_CANCEL -> "REASON_ASSISTANT_CANCEL"
        else -> "unknown"
    }

@@ -241,6 +247,81 @@ class NotifCollectionLogger @Inject constructor(
            "ERROR suppressed due to initialization forgiveness: $str1"
        })
    }

    fun logFutureDismissalReused(dismissal: FutureDismissal) {
        buffer.log(TAG, INFO, {
            str1 = dismissal.label
        }, {
            "Reusing existing registration: $str1"
        })
    }

    fun logFutureDismissalRegistered(dismissal: FutureDismissal) {
        buffer.log(TAG, DEBUG, {
            str1 = dismissal.label
        }, {
            "Registered: $str1"
        })
    }

    fun logFutureDismissalDoubleCancelledByServer(dismissal: FutureDismissal) {
        buffer.log(TAG, WARNING, {
            str1 = dismissal.label
        }, {
            "System server double cancelled: $str1"
        })
    }

    fun logFutureDismissalDoubleRun(dismissal: FutureDismissal) {
        buffer.log(TAG, WARNING, {
            str1 = dismissal.label
        }, {
            "Double run: $str1"
        })
    }

    fun logFutureDismissalAlreadyCancelledByServer(dismissal: FutureDismissal) {
        buffer.log(TAG, DEBUG, {
            str1 = dismissal.label
        }, {
            "Ignoring: entry already cancelled by server: $str1"
        })
    }

    fun logFutureDismissalGotSystemServerCancel(
        dismissal: FutureDismissal,
        @CancellationReason cancellationReason: Int
    ) {
        buffer.log(TAG, DEBUG, {
            str1 = dismissal.label
            int1 = cancellationReason
        }, {
            "SystemServer cancelled: $str1 reason=${cancellationReasonDebugString(int1)}"
        })
    }

    fun logFutureDismissalDismissing(dismissal: FutureDismissal, type: String) {
        buffer.log(TAG, DEBUG, {
            str1 = dismissal.label
            str2 = type
        }, {
            "Dismissing $str2 for: $str1"
        })
    }

    fun logFutureDismissalMismatchedEntry(
        dismissal: FutureDismissal,
        type: String,
        latestEntry: NotificationEntry?
    ) {
        buffer.log(TAG, WARNING, {
            str1 = dismissal.label
            str2 = type
            str3 = latestEntry.logKey
        }, {
            "Mismatch: current $str2 is $str3 for: $str1"
        })
    }
}

private const val TAG = "NotifCollection"
Loading