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

Commit a944ea3e authored by Ned Burns's avatar Ned Burns
Browse files

Add GroupCoalescer to new pipeline

Adds the GroupCoalescer, which attempts to make posting notification
groups an atomic action. Currently, notif groups are posted in pieces,
with one post per child or summary. As a result, downstream code can't
tell whether a group is currently complete or whether more stuff is
coming down the line. This complicates a lot of logic, especially to do
with heads-upping.

The GroupCoalescer sits between the NotificationListener and the
NotifCollection and controls the flow of notification events between the
two. As a result, the full pipeline is now:

NotificationListener -> GroupCoalescer -> NotifCollection ->
NotifListBuilderImpl

Most events pass through the GroupCoalescer unhindered, but any event
that involves posting a grouped notification is temporarily delayed
within the coalescer to see if any other similar events occur. When the
delay times out, all delayed events for that group are posted to the
NotifCollection in a batch.

It's dangerous to reorder or delay events from the NotificationListener
for too long, so any event that would further modify the group, such as
updating or removing one of the delayed notifications, causes the batch
to be immediately emitted, followed by the modifying event.

Test: atest SystemUITests
Change-Id: I4b5dd1a6acb3a7704b2e199a5ed42fe855ab74cb
parent b175ed94
Loading
Loading
Loading
Loading
+55 −27
Original line number Diff line number Diff line
@@ -47,8 +47,9 @@ import android.util.ArrayMap;
import android.util.Log;

import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.statusbar.NotificationListener;
import com.android.systemui.statusbar.NotificationListener.NotificationHandler;
import com.android.systemui.statusbar.notification.collection.notifcollection.CoalescedEvent;
import com.android.systemui.statusbar.notification.collection.notifcollection.GroupCoalescer;
import com.android.systemui.statusbar.notification.collection.notifcollection.GroupCoalescer.BatchableNotificationHandler;
import com.android.systemui.util.Assert;

import java.lang.annotation.Retention;
@@ -108,14 +109,14 @@ public class NotifCollection {
    }

    /** Initializes the NotifCollection and registers it to receive notification events. */
    public void attach(NotificationListener listenerService) {
    public void attach(GroupCoalescer groupCoalescer) {
        Assert.isMainThread();
        if (mAttached) {
            throw new RuntimeException("attach() called twice");
        }
        mAttached = true;

        listenerService.addNotificationHandler(mNotificationHandler);
        groupCoalescer.setNotificationHandler(mNotifHandler);
    }

    /**
@@ -178,15 +179,52 @@ public class NotifCollection {
    private void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
        Assert.isMainThread();

        postNotification(sbn, requireRanking(rankingMap, sbn.getKey()), rankingMap);
        rebuildList();
    }

    private void onNotificationGroupPosted(List<CoalescedEvent> batch) {
        Assert.isMainThread();

        Log.d(TAG, "POSTED GROUP " + batch.get(0).getSbn().getGroupKey()
                + " (" + batch.size() + " events)");
        for (CoalescedEvent event : batch) {
            postNotification(event.getSbn(), event.getRanking(), null);
        }
        rebuildList();
    }

    private void onNotificationRemoved(
            StatusBarNotification sbn,
            RankingMap rankingMap,
            int reason) {
        Assert.isMainThread();

        Log.d(TAG, "REMOVED " + sbn.getKey() + " reason=" + reason);
        removeNotification(sbn.getKey(), rankingMap, reason, null);
    }

    private void onNotificationRankingUpdate(RankingMap rankingMap) {
        Assert.isMainThread();
        applyRanking(rankingMap);
        rebuildList();
    }

    private void postNotification(
            StatusBarNotification sbn,
            Ranking ranking,
            @Nullable RankingMap rankingMap) {
        NotificationEntry entry = mNotificationSet.get(sbn.getKey());

        if (entry == null) {
            // A new notification!
            Log.d(TAG, "POSTED  " + sbn.getKey());

            entry = new NotificationEntry(sbn, requireRanking(rankingMap, sbn.getKey()));
            entry = new NotificationEntry(sbn, ranking);
            mNotificationSet.put(sbn.getKey(), entry);
            if (rankingMap != null) {
                applyRanking(rankingMap);
            }

            dispatchOnEntryAdded(entry);

@@ -199,34 +237,19 @@ public class NotifCollection {
            cancelLifetimeExtension(entry);

            entry.setSbn(sbn);
            if (rankingMap != null) {
                applyRanking(rankingMap);

            dispatchOnEntryUpdated(entry);
        }

        rebuildList();
            }

    private void onNotificationRemoved(
            StatusBarNotification sbn,
            @Nullable RankingMap rankingMap,
            int reason) {
        Assert.isMainThread();
        Log.d(TAG, "REMOVED " + sbn.getKey() + " reason=" + reason);
        removeNotification(sbn.getKey(), rankingMap, reason, null);
            dispatchOnEntryUpdated(entry);
        }

    private void onNotificationRankingUpdate(RankingMap rankingMap) {
        Assert.isMainThread();
        applyRanking(rankingMap);
        rebuildList();
    }

    private void removeNotification(
            String key,
            @Nullable RankingMap rankingMap,
            @CancellationReason int reason,
            DismissedByUserStats dismissedByUserStats) {
            @Nullable DismissedByUserStats dismissedByUserStats) {

        NotificationEntry entry = mNotificationSet.get(key);
        if (entry == null) {
@@ -271,7 +294,7 @@ public class NotifCollection {
        rebuildList();
    }

    private void applyRanking(RankingMap rankingMap) {
    private void applyRanking(@NonNull RankingMap rankingMap) {
        for (NotificationEntry entry : mNotificationSet.values()) {
            if (!isLifetimeExtended(entry)) {
                Ranking ranking = requireRanking(rankingMap, entry.getKey());
@@ -363,12 +386,17 @@ public class NotifCollection {
        mAmDispatchingToOtherCode = false;
    }

    private final NotificationHandler mNotificationHandler = new NotificationHandler() {
    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);
+14 −4
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import com.android.systemui.statusbar.NotificationListener;
import com.android.systemui.statusbar.notification.collection.NotifCollection;
import com.android.systemui.statusbar.notification.collection.NotifListBuilderImpl;
import com.android.systemui.statusbar.notification.collection.coordinator.NotifCoordinators;
import com.android.systemui.statusbar.notification.collection.notifcollection.GroupCoalescer;

import java.io.FileDescriptor;
import java.io.PrintWriter;
@@ -36,6 +37,7 @@ import javax.inject.Singleton;
 */
@Singleton
public class NewNotifPipeline implements Dumpable {
    private final GroupCoalescer mGroupCoalescer;
    private final NotifCollection mNotifCollection;
    private final NotifListBuilderImpl mNotifPipeline;
    private final NotifCoordinators mNotifPluggableCoordinators;
@@ -45,10 +47,12 @@ public class NewNotifPipeline implements Dumpable {

    @Inject
    public NewNotifPipeline(
            GroupCoalescer groupCoalescer,
            NotifCollection notifCollection,
            NotifListBuilderImpl notifPipeline,
            NotifCoordinators notifCoordinators,
            DumpController dumpController) {
        mGroupCoalescer = groupCoalescer;
        mNotifCollection = notifCollection;
        mNotifPipeline = notifPipeline;
        mNotifPluggableCoordinators = notifCoordinators;
@@ -58,20 +62,26 @@ public class NewNotifPipeline implements Dumpable {
    /** Hooks the new pipeline up to NotificationManager */
    public void initialize(
            NotificationListener notificationService) {

        mDumpController.registerDumpable("NotifPipeline", this);

        // Wire up coordinators
        mFakePipelineConsumer.attach(mNotifPipeline);
        mNotifPipeline.attach(mNotifCollection);
        mNotifCollection.attach(notificationService);
        mNotifPluggableCoordinators.attach(mNotifCollection, mNotifPipeline);

        Log.d(TAG, "Notif pipeline initialized");
        // Wire up pipeline
        mNotifPipeline.attach(mNotifCollection);
        mNotifCollection.attach(mGroupCoalescer);
        mGroupCoalescer.attach(notificationService);

        mDumpController.registerDumpable("NotifPipeline", this);
        Log.d(TAG, "Notif pipeline initialized");
    }

    @Override
    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
        mFakePipelineConsumer.dump(fd, pw, args);
        mNotifPluggableCoordinators.dump(fd, pw, args);
        mGroupCoalescer.dump(fd, pw, args);
    }

    private static final String TAG = "NewNotifPipeline";
+32 −0
Original line number Diff line number Diff line
/*
 * 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.notifcollection

import android.service.notification.NotificationListenerService.Ranking
import android.service.notification.StatusBarNotification

data class CoalescedEvent(
    val key: String,
    var position: Int,
    var sbn: StatusBarNotification,
    var ranking: Ranking,
    var batch: EventBatch?
) {
    override fun toString(): String {
        return "CoalescedEvent(key=$key)"
    }
}
+42 −0
Original line number Diff line number Diff line
/*
 * 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.notifcollection;

import java.util.ArrayList;
import java.util.List;

/**
 * Represents a set of notification post events for a particular notification group.
 */
public class EventBatch {
    /** SystemClock.uptimeMillis() */
    final long mCreatedTimestamp;

    /** SBN.getGroupKey -- same for all members */
    final String mGroupKey;

    /**
     * All members of the batch. Must share the same group key. Includes both children and
     * summaries.
     */
    final List<CoalescedEvent> mMembers = new ArrayList<>();

    EventBatch(long createdTimestamp, String groupKey) {
        mCreatedTimestamp = createdTimestamp;
        this.mGroupKey = groupKey;
    }
}
+303 −0
Original line number Diff line number Diff line
/*
 * 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.notifcollection;

import static com.android.systemui.statusbar.notification.logging.NotifEvent.COALESCED_EVENT;
import static com.android.systemui.statusbar.notification.logging.NotifEvent.EARLY_BATCH_EMIT;
import static com.android.systemui.statusbar.notification.logging.NotifEvent.EMIT_EVENT_BATCH;

import static java.util.Objects.requireNonNull;

import android.annotation.MainThread;
import android.service.notification.NotificationListenerService.Ranking;
import android.service.notification.NotificationListenerService.RankingMap;
import android.service.notification.StatusBarNotification;
import android.util.ArrayMap;

import androidx.annotation.NonNull;

import com.android.systemui.Dumpable;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.statusbar.NotificationListener;
import com.android.systemui.statusbar.NotificationListener.NotificationHandler;
import com.android.systemui.statusbar.notification.logging.NotifLog;
import com.android.systemui.util.concurrency.DelayableExecutor;
import com.android.systemui.util.time.SystemClock;

import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

import javax.inject.Inject;

/**
 * An attempt to make posting notification groups an atomic process
 *
 * Due to the nature of the groups API, individual members of a group are posted to system server
 * one at a time. This means that whenever a group member is posted, we don't know if there are any
 * more members soon to be posted.
 *
 * The Coalescer sits between the NotificationListenerService and the NotifCollection. It clusters
 * new notifications that are members of groups and delays their posting until any of the following
 * criteria are met:
 *
 * - A few milliseconds pass (see groupLingerDuration on the constructor)
 * - Any notification in the delayed group is updated
 * - Any notification in the delayed group is retracted
 *
 * Once we cross this threshold, all members of the group in question are posted atomically to the
 * NotifCollection. If this process was triggered by an update or removal, then that event is then
 * passed along to the NotifCollection.
 */
@MainThread
public class GroupCoalescer implements Dumpable {
    private final DelayableExecutor mMainExecutor;
    private final SystemClock mClock;
    private final NotifLog mLog;
    private final long mGroupLingerDuration;

    private BatchableNotificationHandler mHandler;

    private final Map<String, CoalescedEvent> mCoalescedEvents = new ArrayMap<>();
    private final Map<String, EventBatch> mBatches = new ArrayMap<>();

    @Inject
    public GroupCoalescer(
            @Main DelayableExecutor mainExecutor,
            SystemClock clock, NotifLog log) {
        this(mainExecutor, clock, log, GROUP_LINGER_DURATION);
    }

    /**
     * @param groupLingerDuration How long, in ms, that notifications that are members of a group
     *                            are delayed within the GroupCoalescer before being posted
     */
    GroupCoalescer(
            @Main DelayableExecutor mainExecutor,
            SystemClock clock,
            NotifLog log,
            long groupLingerDuration) {
        mMainExecutor = mainExecutor;
        mClock = clock;
        mLog = log;
        mGroupLingerDuration = groupLingerDuration;
    }

    /**
     * Attaches the coalescer to the pipeline, making it ready to receive events. Should only be
     * called once.
     */
    public void attach(NotificationListener listenerService) {
        listenerService.addNotificationHandler(mListener);
    }

    public void setNotificationHandler(BatchableNotificationHandler handler) {
        mHandler = handler;
    }

    private final NotificationHandler mListener = new NotificationHandler() {
        @Override
        public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
            maybeEmitBatch(sbn.getKey());
            applyRanking(rankingMap);

            final boolean shouldCoalesce = handleNotificationPosted(sbn, rankingMap);

            if (shouldCoalesce) {
                mLog.log(COALESCED_EVENT, String.format("Coalesced notification %s", sbn.getKey()));
                mHandler.onNotificationRankingUpdate(rankingMap);
            } else {
                mHandler.onNotificationPosted(sbn, rankingMap);
            }
        }

        @Override
        public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap) {
            maybeEmitBatch(sbn.getKey());
            applyRanking(rankingMap);
            mHandler.onNotificationRemoved(sbn, rankingMap);
        }

        @Override
        public void onNotificationRemoved(
                StatusBarNotification sbn,
                RankingMap rankingMap,
                int reason) {
            maybeEmitBatch(sbn.getKey());
            applyRanking(rankingMap);
            mHandler.onNotificationRemoved(sbn, rankingMap, reason);
        }

        @Override
        public void onNotificationRankingUpdate(RankingMap rankingMap) {
            applyRanking(rankingMap);
            mHandler.onNotificationRankingUpdate(rankingMap);
        }
    };

    private void maybeEmitBatch(String memberKey) {
        CoalescedEvent event = mCoalescedEvents.get(memberKey);
        if (event != null) {
            mLog.log(EARLY_BATCH_EMIT,
                    String.format("Modification of %s triggered early emit of batched group %s",
                            memberKey, requireNonNull(event.getBatch()).mGroupKey));
            emitBatch(requireNonNull(event.getBatch()));
        }
    }

    /**
     * @return True if the notification was coalesced and false otherwise.
     */
    private boolean handleNotificationPosted(
            StatusBarNotification sbn,
            RankingMap rankingMap) {

        if (mCoalescedEvents.containsKey(sbn.getKey())) {
            throw new IllegalStateException(
                    "Notification has already been coalesced: " + sbn.getKey());
        }

        if (sbn.isGroup()) {
            EventBatch batch = startBatchingGroup(sbn.getGroupKey());
            CoalescedEvent event =
                    new CoalescedEvent(
                            sbn.getKey(),
                            batch.mMembers.size(),
                            sbn,
                            requireRanking(rankingMap, sbn.getKey()),
                            batch);

            batch.mMembers.add(event);

            mCoalescedEvents.put(event.getKey(), event);

            return true;
        } else {
            return false;
        }
    }

    private EventBatch startBatchingGroup(final String groupKey) {
        EventBatch batch = mBatches.get(groupKey);
        if (batch == null) {
            final EventBatch newBatch = new EventBatch(mClock.uptimeMillis(), groupKey);
            mBatches.put(groupKey, newBatch);
            mMainExecutor.executeDelayed(() -> emitBatch(newBatch), mGroupLingerDuration);

            batch = newBatch;
        }
        return batch;
    }

    private void emitBatch(EventBatch batch) {
        if (batch != mBatches.get(batch.mGroupKey)) {
            // If we emit a batch early, we don't want to emit it a second time when its timeout
            // expires.
            return;
        }
        if (batch.mMembers.isEmpty()) {
            throw new IllegalStateException("Batch " + batch.mGroupKey + " cannot be empty");
        }

        mBatches.remove(batch.mGroupKey);

        final List<CoalescedEvent> events = new ArrayList<>(batch.mMembers);
        for (CoalescedEvent event : events) {
            mCoalescedEvents.remove(event.getKey());
            event.setBatch(null);
        }
        events.sort(mEventComparator);

        mLog.log(EMIT_EVENT_BATCH, "Emitting event batch for group " + batch.mGroupKey);

        mHandler.onNotificationBatchPosted(events);
    }

    private Ranking requireRanking(RankingMap rankingMap, String key) {
        Ranking ranking = new Ranking();
        if (!rankingMap.getRanking(key, ranking)) {
            throw new IllegalArgumentException("Ranking map does not contain key " + key);
        }
        return ranking;
    }

    private void applyRanking(RankingMap rankingMap) {
        for (CoalescedEvent event : mCoalescedEvents.values()) {
            Ranking ranking = new Ranking();
            if (!rankingMap.getRanking(event.getKey(), ranking)) {
                throw new IllegalStateException(
                        "Ranking map doesn't contain key: " + event.getKey());
            }
            event.setRanking(ranking);
        }
    }

    @Override
    public void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) {
        long now = mClock.uptimeMillis();

        int eventCount = 0;

        pw.println();
        pw.println("Coalesced notifications:");
        for (EventBatch batch : mBatches.values()) {
            pw.println("   Batch " + batch.mGroupKey + ":");
            pw.println("       Created" + (now - batch.mCreatedTimestamp) + "ms ago");
            for (CoalescedEvent event : batch.mMembers) {
                pw.println("       " + event.getKey());
                eventCount++;
            }
        }

        if (eventCount != mCoalescedEvents.size()) {
            pw.println("    ERROR: batches contain " + mCoalescedEvents.size() + " events but"
                    + " am tracking " + mCoalescedEvents.size() + " total events");
            pw.println("    All tracked events:");
            for (CoalescedEvent event : mCoalescedEvents.values()) {
                pw.println("        " + event.getKey());
            }
        }
    }

    private final Comparator<CoalescedEvent> mEventComparator = (o1, o2) -> {
        int cmp = Boolean.compare(
                o2.getSbn().getNotification().isGroupSummary(),
                o1.getSbn().getNotification().isGroupSummary());
        if (cmp == 0) {
            cmp = o1.getPosition() - o2.getPosition();
        }
        return cmp;
    };

    /**
     * Extension of {@link NotificationListener.NotificationHandler} to include notification
     * groups.
     */
    public interface BatchableNotificationHandler extends NotificationHandler {
        /**
         * Fired whenever the coalescer needs to emit a batch of multiple post events. This is
         * usually the addition of a new group, but can contain just a single event, or just an
         * update to a subset of an existing group.
         */
        void onNotificationBatchPosted(List<CoalescedEvent> events);
    }

    private static final int GROUP_LINGER_DURATION = 40;
}
Loading