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