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