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