1 /* 2 * Copyright (C) 2017 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 package com.android.systemui.statusbar.notification.logging; 17 18 import android.content.Context; 19 import android.os.Handler; 20 import android.os.RemoteException; 21 import android.os.ServiceManager; 22 import android.os.SystemClock; 23 import android.service.notification.NotificationListenerService; 24 import android.service.notification.NotificationStats; 25 import android.service.notification.StatusBarNotification; 26 import android.util.ArrayMap; 27 import android.util.ArraySet; 28 import android.util.Log; 29 30 import androidx.annotation.Nullable; 31 32 import com.android.internal.annotations.VisibleForTesting; 33 import com.android.internal.statusbar.IStatusBarService; 34 import com.android.internal.statusbar.NotificationVisibility; 35 import com.android.systemui.UiOffloadThread; 36 import com.android.systemui.plugins.statusbar.StatusBarStateController; 37 import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener; 38 import com.android.systemui.statusbar.NotificationListener; 39 import com.android.systemui.statusbar.notification.NotificationEntryListener; 40 import com.android.systemui.statusbar.notification.NotificationEntryManager; 41 import com.android.systemui.statusbar.notification.collection.NotificationEntry; 42 import com.android.systemui.statusbar.notification.stack.ExpandableViewState; 43 import com.android.systemui.statusbar.notification.stack.NotificationListContainer; 44 import com.android.systemui.statusbar.policy.HeadsUpManager; 45 46 import java.util.ArrayList; 47 import java.util.Collection; 48 import java.util.Collections; 49 import java.util.Map; 50 51 import javax.inject.Inject; 52 import javax.inject.Singleton; 53 54 /** 55 * Handles notification logging, in particular, logging which notifications are visible and which 56 * are not. 57 */ 58 @Singleton 59 public class NotificationLogger implements StateListener { 60 private static final String TAG = "NotificationLogger"; 61 62 /** The minimum delay in ms between reports of notification visibility. */ 63 private static final int VISIBILITY_REPORT_MIN_DELAY_MS = 500; 64 65 /** Keys of notifications currently visible to the user. */ 66 private final ArraySet<NotificationVisibility> mCurrentlyVisibleNotifications = 67 new ArraySet<>(); 68 69 // Dependencies: 70 private final NotificationListenerService mNotificationListener; 71 private final UiOffloadThread mUiOffloadThread; 72 private final NotificationEntryManager mEntryManager; 73 private HeadsUpManager mHeadsUpManager; 74 private final ExpansionStateLogger mExpansionStateLogger; 75 76 protected Handler mHandler = new Handler(); 77 protected IStatusBarService mBarService; 78 private long mLastVisibilityReportUptimeMs; 79 private NotificationListContainer mListContainer; 80 private final Object mDozingLock = new Object(); 81 private boolean mDozing; 82 83 protected final OnChildLocationsChangedListener mNotificationLocationsChangedListener = 84 new OnChildLocationsChangedListener() { 85 @Override 86 public void onChildLocationsChanged() { 87 if (mHandler.hasCallbacks(mVisibilityReporter)) { 88 // Visibilities will be reported when the existing 89 // callback is executed. 90 return; 91 } 92 // Calculate when we're allowed to run the visibility 93 // reporter. Note that this timestamp might already have 94 // passed. That's OK, the callback will just be executed 95 // ASAP. 96 long nextReportUptimeMs = 97 mLastVisibilityReportUptimeMs + VISIBILITY_REPORT_MIN_DELAY_MS; 98 mHandler.postAtTime(mVisibilityReporter, nextReportUptimeMs); 99 } 100 }; 101 102 // Tracks notifications currently visible in mNotificationStackScroller and 103 // emits visibility events via NoMan on changes. 104 protected Runnable mVisibilityReporter = new Runnable() { 105 private final ArraySet<NotificationVisibility> mTmpNewlyVisibleNotifications = 106 new ArraySet<>(); 107 private final ArraySet<NotificationVisibility> mTmpCurrentlyVisibleNotifications = 108 new ArraySet<>(); 109 private final ArraySet<NotificationVisibility> mTmpNoLongerVisibleNotifications = 110 new ArraySet<>(); 111 112 @Override 113 public void run() { 114 mLastVisibilityReportUptimeMs = SystemClock.uptimeMillis(); 115 116 // 1. Loop over mNotificationData entries: 117 // A. Keep list of visible notifications. 118 // B. Keep list of previously hidden, now visible notifications. 119 // 2. Compute no-longer visible notifications by removing currently 120 // visible notifications from the set of previously visible 121 // notifications. 122 // 3. Report newly visible and no-longer visible notifications. 123 // 4. Keep currently visible notifications for next report. 124 ArrayList<NotificationEntry> activeNotifications = mEntryManager 125 .getNotificationData().getActiveNotifications(); 126 int N = activeNotifications.size(); 127 for (int i = 0; i < N; i++) { 128 NotificationEntry entry = activeNotifications.get(i); 129 String key = entry.notification.getKey(); 130 boolean isVisible = mListContainer.isInVisibleLocation(entry); 131 NotificationVisibility visObj = NotificationVisibility.obtain(key, i, N, isVisible, 132 getNotificationLocation(entry)); 133 boolean previouslyVisible = mCurrentlyVisibleNotifications.contains(visObj); 134 if (isVisible) { 135 // Build new set of visible notifications. 136 mTmpCurrentlyVisibleNotifications.add(visObj); 137 if (!previouslyVisible) { 138 mTmpNewlyVisibleNotifications.add(visObj); 139 } 140 } else { 141 // release object 142 visObj.recycle(); 143 } 144 } 145 mTmpNoLongerVisibleNotifications.addAll(mCurrentlyVisibleNotifications); 146 mTmpNoLongerVisibleNotifications.removeAll(mTmpCurrentlyVisibleNotifications); 147 148 logNotificationVisibilityChanges( 149 mTmpNewlyVisibleNotifications, mTmpNoLongerVisibleNotifications); 150 151 recycleAllVisibilityObjects(mCurrentlyVisibleNotifications); 152 mCurrentlyVisibleNotifications.addAll(mTmpCurrentlyVisibleNotifications); 153 154 mExpansionStateLogger.onVisibilityChanged( 155 mTmpCurrentlyVisibleNotifications, mTmpCurrentlyVisibleNotifications); 156 157 recycleAllVisibilityObjects(mTmpNoLongerVisibleNotifications); 158 mTmpCurrentlyVisibleNotifications.clear(); 159 mTmpNewlyVisibleNotifications.clear(); 160 mTmpNoLongerVisibleNotifications.clear(); 161 } 162 }; 163 164 /** 165 * Returns the location of the notification referenced by the given {@link NotificationEntry}. 166 */ getNotificationLocation( NotificationEntry entry)167 public static NotificationVisibility.NotificationLocation getNotificationLocation( 168 NotificationEntry entry) { 169 if (entry == null || entry.getRow() == null || entry.getRow().getViewState() == null) { 170 return NotificationVisibility.NotificationLocation.LOCATION_UNKNOWN; 171 } 172 return convertNotificationLocation(entry.getRow().getViewState().location); 173 } 174 convertNotificationLocation( int location)175 private static NotificationVisibility.NotificationLocation convertNotificationLocation( 176 int location) { 177 switch (location) { 178 case ExpandableViewState.LOCATION_FIRST_HUN: 179 return NotificationVisibility.NotificationLocation.LOCATION_FIRST_HEADS_UP; 180 case ExpandableViewState.LOCATION_HIDDEN_TOP: 181 return NotificationVisibility.NotificationLocation.LOCATION_HIDDEN_TOP; 182 case ExpandableViewState.LOCATION_MAIN_AREA: 183 return NotificationVisibility.NotificationLocation.LOCATION_MAIN_AREA; 184 case ExpandableViewState.LOCATION_BOTTOM_STACK_PEEKING: 185 return NotificationVisibility.NotificationLocation.LOCATION_BOTTOM_STACK_PEEKING; 186 case ExpandableViewState.LOCATION_BOTTOM_STACK_HIDDEN: 187 return NotificationVisibility.NotificationLocation.LOCATION_BOTTOM_STACK_HIDDEN; 188 case ExpandableViewState.LOCATION_GONE: 189 return NotificationVisibility.NotificationLocation.LOCATION_GONE; 190 default: 191 return NotificationVisibility.NotificationLocation.LOCATION_UNKNOWN; 192 } 193 } 194 195 @Inject NotificationLogger(NotificationListener notificationListener, UiOffloadThread uiOffloadThread, NotificationEntryManager entryManager, StatusBarStateController statusBarStateController, ExpansionStateLogger expansionStateLogger)196 public NotificationLogger(NotificationListener notificationListener, 197 UiOffloadThread uiOffloadThread, 198 NotificationEntryManager entryManager, 199 StatusBarStateController statusBarStateController, 200 ExpansionStateLogger expansionStateLogger) { 201 mNotificationListener = notificationListener; 202 mUiOffloadThread = uiOffloadThread; 203 mEntryManager = entryManager; 204 mBarService = IStatusBarService.Stub.asInterface( 205 ServiceManager.getService(Context.STATUS_BAR_SERVICE)); 206 mExpansionStateLogger = expansionStateLogger; 207 // Not expected to be destroyed, don't need to unsubscribe 208 statusBarStateController.addCallback(this); 209 210 entryManager.addNotificationEntryListener(new NotificationEntryListener() { 211 @Override 212 public void onEntryRemoved( 213 NotificationEntry entry, 214 NotificationVisibility visibility, 215 boolean removedByUser) { 216 if (removedByUser && visibility != null) { 217 logNotificationClear(entry.key, entry.notification, visibility); 218 } 219 mExpansionStateLogger.onEntryRemoved(entry.key); 220 } 221 222 @Override 223 public void onEntryReinflated(NotificationEntry entry) { 224 mExpansionStateLogger.onEntryReinflated(entry.key); 225 } 226 227 @Override 228 public void onInflationError( 229 StatusBarNotification notification, 230 Exception exception) { 231 logNotificationError(notification, exception); 232 } 233 }); 234 } 235 setUpWithContainer(NotificationListContainer listContainer)236 public void setUpWithContainer(NotificationListContainer listContainer) { 237 mListContainer = listContainer; 238 } 239 setHeadsUpManager(HeadsUpManager headsUpManager)240 public void setHeadsUpManager(HeadsUpManager headsUpManager) { 241 mHeadsUpManager = headsUpManager; 242 } 243 stopNotificationLogging()244 public void stopNotificationLogging() { 245 // Report all notifications as invisible and turn down the 246 // reporter. 247 if (!mCurrentlyVisibleNotifications.isEmpty()) { 248 logNotificationVisibilityChanges( 249 Collections.emptyList(), mCurrentlyVisibleNotifications); 250 recycleAllVisibilityObjects(mCurrentlyVisibleNotifications); 251 } 252 mHandler.removeCallbacks(mVisibilityReporter); 253 mListContainer.setChildLocationsChangedListener(null); 254 } 255 startNotificationLogging()256 public void startNotificationLogging() { 257 mListContainer.setChildLocationsChangedListener(mNotificationLocationsChangedListener); 258 // Some transitions like mVisibleToUser=false -> mVisibleToUser=true don't 259 // cause the scroller to emit child location events. Hence generate 260 // one ourselves to guarantee that we're reporting visible 261 // notifications. 262 // (Note that in cases where the scroller does emit events, this 263 // additional event doesn't break anything.) 264 mNotificationLocationsChangedListener.onChildLocationsChanged(); 265 } 266 setDozing(boolean dozing)267 private void setDozing(boolean dozing) { 268 synchronized (mDozingLock) { 269 mDozing = dozing; 270 } 271 } 272 273 // TODO: This method has side effects, it is NOT just logging that a notification 274 // was cleared, it also actually removes the notification logNotificationClear(String key, StatusBarNotification notification, NotificationVisibility nv)275 private void logNotificationClear(String key, StatusBarNotification notification, 276 NotificationVisibility nv) { 277 final String pkg = notification.getPackageName(); 278 final String tag = notification.getTag(); 279 final int id = notification.getId(); 280 final int userId = notification.getUserId(); 281 try { 282 int dismissalSurface = NotificationStats.DISMISSAL_SHADE; 283 if (mHeadsUpManager.isAlerting(key)) { 284 dismissalSurface = NotificationStats.DISMISSAL_PEEK; 285 } else if (mListContainer.hasPulsingNotifications()) { 286 dismissalSurface = NotificationStats.DISMISSAL_AOD; 287 } 288 int dismissalSentiment = NotificationStats.DISMISS_SENTIMENT_NEUTRAL; 289 mBarService.onNotificationClear(pkg, tag, id, userId, notification.getKey(), 290 dismissalSurface, 291 dismissalSentiment, nv); 292 } catch (RemoteException ex) { 293 // system process is dead if we're here. 294 } 295 } 296 logNotificationError( StatusBarNotification notification, Exception exception)297 private void logNotificationError( 298 StatusBarNotification notification, 299 Exception exception) { 300 try { 301 mBarService.onNotificationError( 302 notification.getPackageName(), 303 notification.getTag(), 304 notification.getId(), 305 notification.getUid(), 306 notification.getInitialPid(), 307 exception.getMessage(), 308 notification.getUserId()); 309 } catch (RemoteException ex) { 310 // The end is nigh. 311 } 312 } 313 logNotificationVisibilityChanges( Collection<NotificationVisibility> newlyVisible, Collection<NotificationVisibility> noLongerVisible)314 private void logNotificationVisibilityChanges( 315 Collection<NotificationVisibility> newlyVisible, 316 Collection<NotificationVisibility> noLongerVisible) { 317 if (newlyVisible.isEmpty() && noLongerVisible.isEmpty()) { 318 return; 319 } 320 final NotificationVisibility[] newlyVisibleAr = cloneVisibilitiesAsArr(newlyVisible); 321 final NotificationVisibility[] noLongerVisibleAr = cloneVisibilitiesAsArr(noLongerVisible); 322 323 mUiOffloadThread.submit(() -> { 324 try { 325 mBarService.onNotificationVisibilityChanged(newlyVisibleAr, noLongerVisibleAr); 326 } catch (RemoteException e) { 327 // Ignore. 328 } 329 330 final int N = newlyVisibleAr.length; 331 if (N > 0) { 332 String[] newlyVisibleKeyAr = new String[N]; 333 for (int i = 0; i < N; i++) { 334 newlyVisibleKeyAr[i] = newlyVisibleAr[i].key; 335 } 336 337 synchronized (mDozingLock) { 338 // setNotificationsShown should only be called if we are confident that 339 // the user has seen the notification, aka not when ambient display is on 340 if (!mDozing) { 341 // TODO: Call NotificationEntryManager to do this, once it exists. 342 // TODO: Consider not catching all runtime exceptions here. 343 try { 344 mNotificationListener.setNotificationsShown(newlyVisibleKeyAr); 345 } catch (RuntimeException e) { 346 Log.d(TAG, "failed setNotificationsShown: ", e); 347 } 348 } 349 } 350 } 351 recycleAllVisibilityObjects(newlyVisibleAr); 352 recycleAllVisibilityObjects(noLongerVisibleAr); 353 }); 354 } 355 recycleAllVisibilityObjects(ArraySet<NotificationVisibility> array)356 private void recycleAllVisibilityObjects(ArraySet<NotificationVisibility> array) { 357 final int N = array.size(); 358 for (int i = 0 ; i < N; i++) { 359 array.valueAt(i).recycle(); 360 } 361 array.clear(); 362 } 363 recycleAllVisibilityObjects(NotificationVisibility[] array)364 private void recycleAllVisibilityObjects(NotificationVisibility[] array) { 365 final int N = array.length; 366 for (int i = 0 ; i < N; i++) { 367 if (array[i] != null) { 368 array[i].recycle(); 369 } 370 } 371 } 372 cloneVisibilitiesAsArr( Collection<NotificationVisibility> c)373 private static NotificationVisibility[] cloneVisibilitiesAsArr( 374 Collection<NotificationVisibility> c) { 375 final NotificationVisibility[] array = new NotificationVisibility[c.size()]; 376 int i = 0; 377 for(NotificationVisibility nv: c) { 378 if (nv != null) { 379 array[i] = nv.clone(); 380 } 381 i++; 382 } 383 return array; 384 } 385 386 @VisibleForTesting getVisibilityReporter()387 public Runnable getVisibilityReporter() { 388 return mVisibilityReporter; 389 } 390 391 @Override onStateChanged(int newState)392 public void onStateChanged(int newState) { 393 // don't care about state change 394 } 395 396 @Override onDozingChanged(boolean isDozing)397 public void onDozingChanged(boolean isDozing) { 398 setDozing(isDozing); 399 } 400 401 /** 402 * Called when the notification is expanded / collapsed. 403 */ onExpansionChanged(String key, boolean isUserAction, boolean isExpanded)404 public void onExpansionChanged(String key, boolean isUserAction, boolean isExpanded) { 405 NotificationVisibility.NotificationLocation location = 406 getNotificationLocation(mEntryManager.getNotificationData().get(key)); 407 mExpansionStateLogger.onExpansionChanged(key, isUserAction, isExpanded, location); 408 } 409 410 @VisibleForTesting setVisibilityReporter(Runnable visibilityReporter)411 public void setVisibilityReporter(Runnable visibilityReporter) { 412 mVisibilityReporter = visibilityReporter; 413 } 414 415 /** 416 * A listener that is notified when some child locations might have changed. 417 */ 418 public interface OnChildLocationsChangedListener { onChildLocationsChanged()419 void onChildLocationsChanged(); 420 } 421 422 /** 423 * Logs the expansion state change when the notification is visible. 424 */ 425 public static class ExpansionStateLogger { 426 /** Notification key -> state, should be accessed in UI offload thread only. */ 427 private final Map<String, State> mExpansionStates = new ArrayMap<>(); 428 429 /** 430 * Notification key -> last logged expansion state, should be accessed in UI thread only. 431 */ 432 private final Map<String, Boolean> mLoggedExpansionState = new ArrayMap<>(); 433 private final UiOffloadThread mUiOffloadThread; 434 @VisibleForTesting 435 IStatusBarService mBarService; 436 437 @Inject ExpansionStateLogger(UiOffloadThread uiOffloadThread)438 public ExpansionStateLogger(UiOffloadThread uiOffloadThread) { 439 mUiOffloadThread = uiOffloadThread; 440 mBarService = 441 IStatusBarService.Stub.asInterface( 442 ServiceManager.getService(Context.STATUS_BAR_SERVICE)); 443 } 444 445 @VisibleForTesting onExpansionChanged(String key, boolean isUserAction, boolean isExpanded, NotificationVisibility.NotificationLocation location)446 void onExpansionChanged(String key, boolean isUserAction, boolean isExpanded, 447 NotificationVisibility.NotificationLocation location) { 448 State state = getState(key); 449 state.mIsUserAction = isUserAction; 450 state.mIsExpanded = isExpanded; 451 state.mLocation = location; 452 maybeNotifyOnNotificationExpansionChanged(key, state); 453 } 454 455 @VisibleForTesting onVisibilityChanged( Collection<NotificationVisibility> newlyVisible, Collection<NotificationVisibility> noLongerVisible)456 void onVisibilityChanged( 457 Collection<NotificationVisibility> newlyVisible, 458 Collection<NotificationVisibility> noLongerVisible) { 459 final NotificationVisibility[] newlyVisibleAr = 460 cloneVisibilitiesAsArr(newlyVisible); 461 final NotificationVisibility[] noLongerVisibleAr = 462 cloneVisibilitiesAsArr(noLongerVisible); 463 464 for (NotificationVisibility nv : newlyVisibleAr) { 465 State state = getState(nv.key); 466 state.mIsVisible = true; 467 state.mLocation = nv.location; 468 maybeNotifyOnNotificationExpansionChanged(nv.key, state); 469 } 470 for (NotificationVisibility nv : noLongerVisibleAr) { 471 State state = getState(nv.key); 472 state.mIsVisible = false; 473 } 474 } 475 476 @VisibleForTesting onEntryRemoved(String key)477 void onEntryRemoved(String key) { 478 mExpansionStates.remove(key); 479 mLoggedExpansionState.remove(key); 480 } 481 482 @VisibleForTesting onEntryReinflated(String key)483 void onEntryReinflated(String key) { 484 // When the notification is updated, we should consider the notification as not 485 // yet logged. 486 mLoggedExpansionState.remove(key); 487 } 488 getState(String key)489 private State getState(String key) { 490 State state = mExpansionStates.get(key); 491 if (state == null) { 492 state = new State(); 493 mExpansionStates.put(key, state); 494 } 495 return state; 496 } 497 maybeNotifyOnNotificationExpansionChanged(final String key, State state)498 private void maybeNotifyOnNotificationExpansionChanged(final String key, State state) { 499 if (!state.isFullySet()) { 500 return; 501 } 502 if (!state.mIsVisible) { 503 return; 504 } 505 Boolean loggedExpansionState = mLoggedExpansionState.get(key); 506 // Consider notification is initially collapsed, so only expanded is logged in the 507 // first time. 508 if (loggedExpansionState == null && !state.mIsExpanded) { 509 return; 510 } 511 if (loggedExpansionState != null 512 && state.mIsExpanded == loggedExpansionState) { 513 return; 514 } 515 mLoggedExpansionState.put(key, state.mIsExpanded); 516 final State stateToBeLogged = new State(state); 517 mUiOffloadThread.submit(() -> { 518 try { 519 mBarService.onNotificationExpansionChanged(key, stateToBeLogged.mIsUserAction, 520 stateToBeLogged.mIsExpanded, stateToBeLogged.mLocation.ordinal()); 521 } catch (RemoteException e) { 522 Log.e(TAG, "Failed to call onNotificationExpansionChanged: ", e); 523 } 524 }); 525 } 526 527 private static class State { 528 @Nullable 529 Boolean mIsUserAction; 530 @Nullable 531 Boolean mIsExpanded; 532 @Nullable 533 Boolean mIsVisible; 534 @Nullable 535 NotificationVisibility.NotificationLocation mLocation; 536 State()537 private State() {} 538 State(State state)539 private State(State state) { 540 this.mIsUserAction = state.mIsUserAction; 541 this.mIsExpanded = state.mIsExpanded; 542 this.mIsVisible = state.mIsVisible; 543 this.mLocation = state.mLocation; 544 } 545 isFullySet()546 private boolean isFullySet() { 547 return mIsUserAction != null && mIsExpanded != null && mIsVisible != null 548 && mLocation != null; 549 } 550 } 551 } 552 } 553