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