1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.statusbar.notification.collection.coalescer;
18 
19 import static java.util.Objects.requireNonNull;
20 
21 import android.annotation.MainThread;
22 import android.service.notification.NotificationListenerService.Ranking;
23 import android.service.notification.NotificationListenerService.RankingMap;
24 import android.service.notification.StatusBarNotification;
25 import android.util.ArrayMap;
26 
27 import androidx.annotation.NonNull;
28 
29 import com.android.systemui.Dumpable;
30 import com.android.systemui.dagger.qualifiers.Main;
31 import com.android.systemui.statusbar.NotificationListener;
32 import com.android.systemui.statusbar.NotificationListener.NotificationHandler;
33 import com.android.systemui.util.concurrency.DelayableExecutor;
34 import com.android.systemui.util.time.SystemClock;
35 
36 import java.io.FileDescriptor;
37 import java.io.PrintWriter;
38 import java.util.ArrayList;
39 import java.util.Comparator;
40 import java.util.List;
41 import java.util.Map;
42 
43 import javax.inject.Inject;
44 
45 /**
46  * An attempt to make posting notification groups an atomic process
47  *
48  * Due to the nature of the groups API, individual members of a group are posted to system server
49  * one at a time. This means that whenever a group member is posted, we don't know if there are any
50  * more members soon to be posted.
51  *
52  * The Coalescer sits between the NotificationListenerService and the NotifCollection. It clusters
53  * new notifications that are members of groups and delays their posting until any of the following
54  * criteria are met:
55  *
56  * - A few milliseconds pass (see groupLingerDuration on the constructor)
57  * - Any notification in the delayed group is updated
58  * - Any notification in the delayed group is retracted
59  *
60  * Once we cross this threshold, all members of the group in question are posted atomically to the
61  * NotifCollection. If this process was triggered by an update or removal, then that event is then
62  * passed along to the NotifCollection.
63  */
64 @MainThread
65 public class GroupCoalescer implements Dumpable {
66     private final DelayableExecutor mMainExecutor;
67     private final SystemClock mClock;
68     private final GroupCoalescerLogger mLogger;
69     private final long mMinGroupLingerDuration;
70     private final long mMaxGroupLingerDuration;
71 
72     private BatchableNotificationHandler mHandler;
73 
74     private final Map<String, CoalescedEvent> mCoalescedEvents = new ArrayMap<>();
75     private final Map<String, EventBatch> mBatches = new ArrayMap<>();
76 
77     @Inject
GroupCoalescer( @ain DelayableExecutor mainExecutor, SystemClock clock, GroupCoalescerLogger logger)78     public GroupCoalescer(
79             @Main DelayableExecutor mainExecutor,
80             SystemClock clock,
81             GroupCoalescerLogger logger) {
82         this(mainExecutor, clock, logger, MIN_GROUP_LINGER_DURATION, MAX_GROUP_LINGER_DURATION);
83     }
84 
85     /**
86      * @param minGroupLingerDuration How long, in ms, to wait for another notification from the same
87      *                               group to arrive before emitting all pending events for that
88      *                               group. Each subsequent arrival of a group member resets the
89      *                               timer for that group.
90      * @param maxGroupLingerDuration The maximum time, in ms, that a group can linger in the
91      *                               coalescer before it's force-emitted.
92      */
GroupCoalescer( @ain DelayableExecutor mainExecutor, SystemClock clock, GroupCoalescerLogger logger, long minGroupLingerDuration, long maxGroupLingerDuration)93     GroupCoalescer(
94             @Main DelayableExecutor mainExecutor,
95             SystemClock clock,
96             GroupCoalescerLogger logger,
97             long minGroupLingerDuration,
98             long maxGroupLingerDuration) {
99         mMainExecutor = mainExecutor;
100         mClock = clock;
101         mLogger = logger;
102         mMinGroupLingerDuration = minGroupLingerDuration;
103         mMaxGroupLingerDuration = maxGroupLingerDuration;
104     }
105 
106     /**
107      * Attaches the coalescer to the pipeline, making it ready to receive events. Should only be
108      * called once.
109      */
attach(NotificationListener listenerService)110     public void attach(NotificationListener listenerService) {
111         listenerService.addNotificationHandler(mListener);
112     }
113 
setNotificationHandler(BatchableNotificationHandler handler)114     public void setNotificationHandler(BatchableNotificationHandler handler) {
115         mHandler = handler;
116     }
117 
118     private final NotificationHandler mListener = new NotificationHandler() {
119         @Override
120         public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
121             maybeEmitBatch(sbn);
122             applyRanking(rankingMap);
123 
124             final boolean shouldCoalesce = handleNotificationPosted(sbn, rankingMap);
125 
126             if (shouldCoalesce) {
127                 mLogger.logEventCoalesced(sbn.getKey());
128                 mHandler.onNotificationRankingUpdate(rankingMap);
129             } else {
130                 mHandler.onNotificationPosted(sbn, rankingMap);
131             }
132         }
133 
134         @Override
135         public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap) {
136             maybeEmitBatch(sbn);
137             applyRanking(rankingMap);
138             mHandler.onNotificationRemoved(sbn, rankingMap);
139         }
140 
141         @Override
142         public void onNotificationRemoved(
143                 StatusBarNotification sbn,
144                 RankingMap rankingMap,
145                 int reason) {
146             maybeEmitBatch(sbn);
147             applyRanking(rankingMap);
148             mHandler.onNotificationRemoved(sbn, rankingMap, reason);
149         }
150 
151         @Override
152         public void onNotificationRankingUpdate(RankingMap rankingMap) {
153             applyRanking(rankingMap);
154             mHandler.onNotificationRankingUpdate(rankingMap);
155         }
156 
157         @Override
158         public void onNotificationsInitialized() {
159             mHandler.onNotificationsInitialized();
160         }
161     };
162 
maybeEmitBatch(StatusBarNotification sbn)163     private void maybeEmitBatch(StatusBarNotification sbn) {
164         final CoalescedEvent event = mCoalescedEvents.get(sbn.getKey());
165         final EventBatch batch = mBatches.get(sbn.getGroupKey());
166         if (event != null) {
167             mLogger.logEarlyEmit(sbn.getKey(), requireNonNull(event.getBatch()).mGroupKey);
168             emitBatch(requireNonNull(event.getBatch()));
169         } else if (batch != null
170                 && mClock.uptimeMillis() - batch.mCreatedTimestamp >= mMaxGroupLingerDuration) {
171             mLogger.logMaxBatchTimeout(sbn.getKey(), batch.mGroupKey);
172             emitBatch(batch);
173         }
174     }
175 
176     /**
177      * @return True if the notification was coalesced and false otherwise.
178      */
handleNotificationPosted( StatusBarNotification sbn, RankingMap rankingMap)179     private boolean handleNotificationPosted(
180             StatusBarNotification sbn,
181             RankingMap rankingMap) {
182 
183         if (mCoalescedEvents.containsKey(sbn.getKey())) {
184             throw new IllegalStateException(
185                     "Notification has already been coalesced: " + sbn.getKey());
186         }
187 
188         if (sbn.isGroup()) {
189             final EventBatch batch = getOrBuildBatch(sbn.getGroupKey());
190 
191             CoalescedEvent event =
192                     new CoalescedEvent(
193                             sbn.getKey(),
194                             batch.mMembers.size(),
195                             sbn,
196                             requireRanking(rankingMap, sbn.getKey()),
197                             batch);
198             mCoalescedEvents.put(event.getKey(), event);
199 
200             batch.mMembers.add(event);
201             resetShortTimeout(batch);
202 
203             return true;
204         } else {
205             return false;
206         }
207     }
208 
getOrBuildBatch(final String groupKey)209     private EventBatch getOrBuildBatch(final String groupKey) {
210         EventBatch batch = mBatches.get(groupKey);
211         if (batch == null) {
212             batch = new EventBatch(mClock.uptimeMillis(), groupKey);
213             mBatches.put(groupKey, batch);
214         }
215         return batch;
216     }
217 
resetShortTimeout(EventBatch batch)218     private void resetShortTimeout(EventBatch batch) {
219         if (batch.mCancelShortTimeout != null) {
220             batch.mCancelShortTimeout.run();
221         }
222         batch.mCancelShortTimeout =
223                 mMainExecutor.executeDelayed(
224                         () -> {
225                             batch.mCancelShortTimeout = null;
226                             emitBatch(batch);
227                         },
228                         mMinGroupLingerDuration);
229     }
230 
emitBatch(EventBatch batch)231     private void emitBatch(EventBatch batch) {
232         if (batch != mBatches.get(batch.mGroupKey)) {
233             throw new IllegalStateException("Cannot emit out-of-date batch " + batch.mGroupKey);
234         }
235         if (batch.mMembers.isEmpty()) {
236             throw new IllegalStateException("Batch " + batch.mGroupKey + " cannot be empty");
237         }
238         if (batch.mCancelShortTimeout != null) {
239             batch.mCancelShortTimeout.run();
240             batch.mCancelShortTimeout = null;
241         }
242 
243         mBatches.remove(batch.mGroupKey);
244 
245         final List<CoalescedEvent> events = new ArrayList<>(batch.mMembers);
246         for (CoalescedEvent event : events) {
247             mCoalescedEvents.remove(event.getKey());
248             event.setBatch(null);
249         }
250         events.sort(mEventComparator);
251 
252         mLogger.logEmitBatch(batch.mGroupKey);
253 
254         mHandler.onNotificationBatchPosted(events);
255     }
256 
requireRanking(RankingMap rankingMap, String key)257     private Ranking requireRanking(RankingMap rankingMap, String key) {
258         Ranking ranking = new Ranking();
259         if (!rankingMap.getRanking(key, ranking)) {
260             throw new IllegalArgumentException("Ranking map does not contain key " + key);
261         }
262         return ranking;
263     }
264 
applyRanking(RankingMap rankingMap)265     private void applyRanking(RankingMap rankingMap) {
266         for (CoalescedEvent event : mCoalescedEvents.values()) {
267             Ranking ranking = new Ranking();
268             if (rankingMap.getRanking(event.getKey(), ranking)) {
269                 event.setRanking(ranking);
270             } else {
271                 // TODO: (b/148791039) We should crash if we are ever handed a ranking with
272                 //  incomplete entries. Right now, there's a race condition in NotificationListener
273                 //  that means this might occur when SystemUI is starting up.
274                 mLogger.logMissingRanking(event.getKey());
275             }
276         }
277     }
278 
279     @Override
dump(@onNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args)280     public void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) {
281         long now = mClock.uptimeMillis();
282 
283         int eventCount = 0;
284 
285         pw.println();
286         pw.println("Coalesced notifications:");
287         for (EventBatch batch : mBatches.values()) {
288             pw.println("   Batch " + batch.mGroupKey + ":");
289             pw.println("       Created " + (now - batch.mCreatedTimestamp) + "ms ago");
290             for (CoalescedEvent event : batch.mMembers) {
291                 pw.println("       " + event.getKey());
292                 eventCount++;
293             }
294         }
295 
296         if (eventCount != mCoalescedEvents.size()) {
297             pw.println("    ERROR: batches contain " + mCoalescedEvents.size() + " events but"
298                     + " am tracking " + mCoalescedEvents.size() + " total events");
299             pw.println("    All tracked events:");
300             for (CoalescedEvent event : mCoalescedEvents.values()) {
301                 pw.println("        " + event.getKey());
302             }
303         }
304     }
305 
306     private final Comparator<CoalescedEvent> mEventComparator = (o1, o2) -> {
307         int cmp = Boolean.compare(
308                 o2.getSbn().getNotification().isGroupSummary(),
309                 o1.getSbn().getNotification().isGroupSummary());
310         if (cmp == 0) {
311             cmp = o1.getPosition() - o2.getPosition();
312         }
313         return cmp;
314     };
315 
316     /**
317      * Extension of {@link NotificationListener.NotificationHandler} to include notification
318      * groups.
319      */
320     public interface BatchableNotificationHandler extends NotificationHandler {
321         /**
322          * Fired whenever the coalescer needs to emit a batch of multiple post events. This is
323          * usually the addition of a new group, but can contain just a single event, or just an
324          * update to a subset of an existing group.
325          */
onNotificationBatchPosted(List<CoalescedEvent> events)326         void onNotificationBatchPosted(List<CoalescedEvent> events);
327     }
328 
329     private static final int MIN_GROUP_LINGER_DURATION = 50;
330     private static final int MAX_GROUP_LINGER_DURATION = 500;
331 }
332