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