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