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