• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2019 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.bubbles;
17 
18 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
19 
20 import static java.util.stream.Collectors.toList;
21 
22 import android.app.Notification;
23 import android.app.PendingIntent;
24 import android.content.Context;
25 import android.service.notification.NotificationListenerService;
26 import android.service.notification.NotificationListenerService.RankingMap;
27 import android.util.Log;
28 import android.util.Pair;
29 
30 import androidx.annotation.Nullable;
31 
32 import com.android.internal.annotations.VisibleForTesting;
33 import com.android.systemui.bubbles.BubbleController.DismissReason;
34 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
35 
36 import java.util.ArrayList;
37 import java.util.Collections;
38 import java.util.Comparator;
39 import java.util.HashMap;
40 import java.util.Iterator;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.Objects;
44 
45 import javax.inject.Inject;
46 import javax.inject.Singleton;
47 
48 /**
49  * Keeps track of active bubbles.
50  */
51 @Singleton
52 public class BubbleData {
53 
54     private static final String TAG = "BubbleData";
55     private static final boolean DEBUG = false;
56 
57     private static final int MAX_BUBBLES = 5;
58 
59     private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
60             Comparator.comparing(BubbleData::sortKey).reversed();
61 
62     private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_MAX_SORT_KEY_DESCENDING =
63             Comparator.<Map.Entry<String, Long>, Long>comparing(Map.Entry::getValue).reversed();
64 
65     /** Contains information about changes that have been made to the state of bubbles. */
66     static final class Update {
67         boolean expandedChanged;
68         boolean selectionChanged;
69         boolean orderChanged;
70         boolean expanded;
71         @Nullable Bubble selectedBubble;
72         @Nullable Bubble addedBubble;
73         @Nullable Bubble updatedBubble;
74         // Pair with Bubble and @DismissReason Integer
75         final List<Pair<Bubble, Integer>> removedBubbles = new ArrayList<>();
76 
77         // A read-only view of the bubbles list, changes there will be reflected here.
78         final List<Bubble> bubbles;
79 
Update(List<Bubble> bubbleOrder)80         private Update(List<Bubble> bubbleOrder) {
81             bubbles = Collections.unmodifiableList(bubbleOrder);
82         }
83 
anythingChanged()84         boolean anythingChanged() {
85             return expandedChanged
86                     || selectionChanged
87                     || addedBubble != null
88                     || updatedBubble != null
89                     || !removedBubbles.isEmpty()
90                     || orderChanged;
91         }
92 
bubbleRemoved(Bubble bubbleToRemove, @DismissReason int reason)93         void bubbleRemoved(Bubble bubbleToRemove, @DismissReason  int reason) {
94             removedBubbles.add(new Pair<>(bubbleToRemove, reason));
95         }
96     }
97 
98     /**
99      * This interface reports changes to the state and appearance of bubbles which should be applied
100      * as necessary to the UI.
101      */
102     interface Listener {
103         /** Reports changes have have occurred as a result of the most recent operation. */
applyUpdate(Update update)104         void applyUpdate(Update update);
105     }
106 
107     interface TimeSource {
currentTimeMillis()108         long currentTimeMillis();
109     }
110 
111     private final Context mContext;
112     private final List<Bubble> mBubbles;
113     private Bubble mSelectedBubble;
114     private boolean mExpanded;
115 
116     // State tracked during an operation -- keeps track of what listener events to dispatch.
117     private Update mStateChange;
118 
119     private NotificationListenerService.Ranking mTmpRanking;
120 
121     private TimeSource mTimeSource = System::currentTimeMillis;
122 
123     @Nullable
124     private Listener mListener;
125 
126     @Inject
BubbleData(Context context)127     public BubbleData(Context context) {
128         mContext = context;
129         mBubbles = new ArrayList<>();
130         mStateChange = new Update(mBubbles);
131     }
132 
hasBubbles()133     public boolean hasBubbles() {
134         return !mBubbles.isEmpty();
135     }
136 
isExpanded()137     public boolean isExpanded() {
138         return mExpanded;
139     }
140 
hasBubbleWithKey(String key)141     public boolean hasBubbleWithKey(String key) {
142         return getBubbleWithKey(key) != null;
143     }
144 
145     @Nullable
getSelectedBubble()146     public Bubble getSelectedBubble() {
147         return mSelectedBubble;
148     }
149 
setExpanded(boolean expanded)150     public void setExpanded(boolean expanded) {
151         if (DEBUG) {
152             Log.d(TAG, "setExpanded: " + expanded);
153         }
154         setExpandedInternal(expanded);
155         dispatchPendingChanges();
156     }
157 
setSelectedBubble(Bubble bubble)158     public void setSelectedBubble(Bubble bubble) {
159         if (DEBUG) {
160             Log.d(TAG, "setSelectedBubble: " + bubble);
161         }
162         setSelectedBubbleInternal(bubble);
163         dispatchPendingChanges();
164     }
165 
notificationEntryUpdated(NotificationEntry entry)166     public void notificationEntryUpdated(NotificationEntry entry) {
167         if (DEBUG) {
168             Log.d(TAG, "notificationEntryUpdated: " + entry);
169         }
170         Bubble bubble = getBubbleWithKey(entry.key);
171         if (bubble == null) {
172             // Create a new bubble
173             bubble = new Bubble(mContext, entry, this::onBubbleBlocked);
174             doAdd(bubble);
175             trim();
176         } else {
177             // Updates an existing bubble
178             bubble.setEntry(entry);
179             doUpdate(bubble);
180         }
181         if (shouldAutoExpand(entry)) {
182             setSelectedBubbleInternal(bubble);
183             if (!mExpanded) {
184                 setExpandedInternal(true);
185             }
186         } else if (mSelectedBubble == null) {
187             setSelectedBubbleInternal(bubble);
188         }
189         dispatchPendingChanges();
190     }
191 
notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason)192     public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
193         if (DEBUG) {
194             Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
195         }
196         doRemove(entry.key, reason);
197         dispatchPendingChanges();
198     }
199 
200     /**
201      * Called when NotificationListener has received adjusted notification rank and reapplied
202      * filtering and sorting. This is used to dismiss any bubbles which should no longer be shown
203      * due to changes in permissions on the notification channel or the global setting.
204      *
205      * @param rankingMap the updated ranking map from NotificationListenerService
206      */
notificationRankingUpdated(RankingMap rankingMap)207     public void notificationRankingUpdated(RankingMap rankingMap) {
208         if (mTmpRanking == null) {
209             mTmpRanking = new NotificationListenerService.Ranking();
210         }
211 
212         String[] orderedKeys = rankingMap.getOrderedKeys();
213         for (int i = 0; i < orderedKeys.length; i++) {
214             String key = orderedKeys[i];
215             if (hasBubbleWithKey(key)) {
216                 rankingMap.getRanking(key, mTmpRanking);
217                 if (!mTmpRanking.canBubble()) {
218                     doRemove(key, BubbleController.DISMISS_BLOCKED);
219                 }
220             }
221         }
222         dispatchPendingChanges();
223     }
224 
doAdd(Bubble bubble)225     private void doAdd(Bubble bubble) {
226         if (DEBUG) {
227             Log.d(TAG, "doAdd: " + bubble);
228         }
229         int minInsertPoint = 0;
230         boolean newGroup = !hasBubbleWithGroupId(bubble.getGroupId());
231         if (isExpanded()) {
232             // first bubble of a group goes to the beginning, otherwise within the existing group
233             minInsertPoint = newGroup ? 0 : findFirstIndexForGroup(bubble.getGroupId());
234         }
235         if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
236             mStateChange.orderChanged = true;
237         }
238         mStateChange.addedBubble = bubble;
239         if (!isExpanded()) {
240             mStateChange.orderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
241             // Top bubble becomes selected.
242             setSelectedBubbleInternal(mBubbles.get(0));
243         }
244     }
245 
trim()246     private void trim() {
247         if (mBubbles.size() > MAX_BUBBLES) {
248             mBubbles.stream()
249                     // sort oldest first (ascending lastActivity)
250                     .sorted(Comparator.comparingLong(Bubble::getLastActivity))
251                     // skip the selected bubble
252                     .filter((b) -> !b.equals(mSelectedBubble))
253                     .findFirst()
254                     .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
255         }
256     }
257 
doUpdate(Bubble bubble)258     private void doUpdate(Bubble bubble) {
259         if (DEBUG) {
260             Log.d(TAG, "doUpdate: " + bubble);
261         }
262         mStateChange.updatedBubble = bubble;
263         if (!isExpanded()) {
264             // while collapsed, update causes re-pack
265             int prevPos = mBubbles.indexOf(bubble);
266             mBubbles.remove(bubble);
267             int newPos = insertBubble(0, bubble);
268             if (prevPos != newPos) {
269                 packGroup(newPos);
270                 mStateChange.orderChanged = true;
271             }
272             setSelectedBubbleInternal(mBubbles.get(0));
273         }
274     }
275 
doRemove(String key, @DismissReason int reason)276     private void doRemove(String key, @DismissReason int reason) {
277         int indexToRemove = indexForKey(key);
278         if (indexToRemove == -1) {
279             return;
280         }
281         Bubble bubbleToRemove = mBubbles.get(indexToRemove);
282         if (mBubbles.size() == 1) {
283             // Going to become empty, handle specially.
284             setExpandedInternal(false);
285             setSelectedBubbleInternal(null);
286         }
287         if (indexToRemove < mBubbles.size() - 1) {
288             // Removing anything but the last bubble means positions will change.
289             mStateChange.orderChanged = true;
290         }
291         mBubbles.remove(indexToRemove);
292         mStateChange.bubbleRemoved(bubbleToRemove, reason);
293         if (!isExpanded()) {
294             mStateChange.orderChanged |= repackAll();
295         }
296 
297         // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
298         if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
299             // Move selection to the new bubble at the same position.
300             int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
301             Bubble newSelected = mBubbles.get(newIndex);
302             setSelectedBubbleInternal(newSelected);
303         }
304         bubbleToRemove.setDismissed();
305         maybeSendDeleteIntent(reason, bubbleToRemove.entry);
306     }
307 
dismissAll(@ismissReason int reason)308     public void dismissAll(@DismissReason int reason) {
309         if (DEBUG) {
310             Log.d(TAG, "dismissAll: reason=" + reason);
311         }
312         if (mBubbles.isEmpty()) {
313             return;
314         }
315         setExpandedInternal(false);
316         setSelectedBubbleInternal(null);
317         while (!mBubbles.isEmpty()) {
318             Bubble bubble = mBubbles.remove(0);
319             bubble.setDismissed();
320             maybeSendDeleteIntent(reason, bubble.entry);
321             mStateChange.bubbleRemoved(bubble, reason);
322         }
323         dispatchPendingChanges();
324     }
325 
dispatchPendingChanges()326     private void dispatchPendingChanges() {
327         if (mListener != null && mStateChange.anythingChanged()) {
328             mListener.applyUpdate(mStateChange);
329         }
330         mStateChange = new Update(mBubbles);
331     }
332 
333     /**
334      * Requests a change to the selected bubble.
335      *
336      * @param bubble the new selected bubble
337      */
setSelectedBubbleInternal(@ullable Bubble bubble)338     private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
339         if (DEBUG) {
340             Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
341         }
342         if (Objects.equals(bubble, mSelectedBubble)) {
343             return;
344         }
345         if (bubble != null && !mBubbles.contains(bubble)) {
346             Log.e(TAG, "Cannot select bubble which doesn't exist!"
347                     + " (" + bubble + ") bubbles=" + mBubbles);
348             return;
349         }
350         if (mExpanded && bubble != null) {
351             bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
352         }
353         mSelectedBubble = bubble;
354         mStateChange.selectedBubble = bubble;
355         mStateChange.selectionChanged = true;
356     }
357 
358     /**
359      * Requests a change to the expanded state.
360      *
361      * @param shouldExpand the new requested state
362      */
setExpandedInternal(boolean shouldExpand)363     private void setExpandedInternal(boolean shouldExpand) {
364         if (DEBUG) {
365             Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
366         }
367         if (mExpanded == shouldExpand) {
368             return;
369         }
370         if (shouldExpand) {
371             if (mBubbles.isEmpty()) {
372                 Log.e(TAG, "Attempt to expand stack when empty!");
373                 return;
374             }
375             if (mSelectedBubble == null) {
376                 Log.e(TAG, "Attempt to expand stack without selected bubble!");
377                 return;
378             }
379             mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
380             mStateChange.orderChanged |= repackAll();
381         } else if (!mBubbles.isEmpty()) {
382             // Apply ordering and grouping rules from expanded -> collapsed, then save
383             // the result.
384             mStateChange.orderChanged |= repackAll();
385             // Save the state which should be returned to when expanded (with no other changes)
386 
387             if (mBubbles.indexOf(mSelectedBubble) > 0) {
388                 // Move the selected bubble to the top while collapsed.
389                 if (!mSelectedBubble.isOngoing() && mBubbles.get(0).isOngoing()) {
390                     // The selected bubble cannot be raised to the first position because
391                     // there is an ongoing bubble there. Instead, force the top ongoing bubble
392                     // to become selected.
393                     setSelectedBubbleInternal(mBubbles.get(0));
394                 } else {
395                     // Raise the selected bubble (and it's group) up to the front so the selected
396                     // bubble remains on top.
397                     mBubbles.remove(mSelectedBubble);
398                     mBubbles.add(0, mSelectedBubble);
399                     packGroup(0);
400                 }
401             }
402         }
403         mExpanded = shouldExpand;
404         mStateChange.expanded = shouldExpand;
405         mStateChange.expandedChanged = true;
406     }
407 
sortKey(Bubble bubble)408     private static long sortKey(Bubble bubble) {
409         long key = bubble.getLastUpdateTime();
410         if (bubble.isOngoing()) {
411             // Set 2nd highest bit (signed long int), to partition between ongoing and regular
412             key |= 0x4000000000000000L;
413         }
414         return key;
415     }
416 
417     /**
418      * Locates and inserts the bubble into a sorted position. The is inserted
419      * based on sort key, groupId is not considered. A call to {@link #packGroup(int)} may be
420      * required to keep grouping intact.
421      *
422      * @param minPosition the first insert point to consider
423      * @param newBubble the bubble to insert
424      * @return the position where the bubble was inserted
425      */
insertBubble(int minPosition, Bubble newBubble)426     private int insertBubble(int minPosition, Bubble newBubble) {
427         long newBubbleSortKey = sortKey(newBubble);
428         String previousGroupId = null;
429 
430         for (int pos = minPosition; pos < mBubbles.size(); pos++) {
431             Bubble bubbleAtPos = mBubbles.get(pos);
432             String groupIdAtPos = bubbleAtPos.getGroupId();
433             boolean atStartOfGroup = !groupIdAtPos.equals(previousGroupId);
434 
435             if (atStartOfGroup && newBubbleSortKey > sortKey(bubbleAtPos)) {
436                 // Insert before the start of first group which has older bubbles.
437                 mBubbles.add(pos, newBubble);
438                 return pos;
439             }
440             previousGroupId = groupIdAtPos;
441         }
442         mBubbles.add(newBubble);
443         return mBubbles.size() - 1;
444     }
445 
hasBubbleWithGroupId(String groupId)446     private boolean hasBubbleWithGroupId(String groupId) {
447         return mBubbles.stream().anyMatch(b -> b.getGroupId().equals(groupId));
448     }
449 
findFirstIndexForGroup(String appId)450     private int findFirstIndexForGroup(String appId) {
451         for (int i = 0; i < mBubbles.size(); i++) {
452             Bubble bubbleAtPos = mBubbles.get(i);
453             if (bubbleAtPos.getGroupId().equals(appId)) {
454                 return i;
455             }
456         }
457         return 0;
458     }
459 
460     /**
461      * Starting at the given position, moves all bubbles with the same group id to follow. Bubbles
462      * at positions lower than {@code position} are unchanged. Relative order within the group
463      * unchanged. Relative order of any other bubbles are also unchanged.
464      *
465      * @param position the position of the first bubble for the group
466      * @return true if the position of any bubbles has changed as a result
467      */
packGroup(int position)468     private boolean packGroup(int position) {
469         if (DEBUG) {
470             Log.d(TAG, "packGroup: position=" + position);
471         }
472         Bubble groupStart = mBubbles.get(position);
473         final String groupAppId = groupStart.getGroupId();
474         List<Bubble> moving = new ArrayList<>();
475 
476         // Walk backward, collect bubbles within the group
477         for (int i = mBubbles.size() - 1; i > position; i--) {
478             if (mBubbles.get(i).getGroupId().equals(groupAppId)) {
479                 moving.add(0, mBubbles.get(i));
480             }
481         }
482         if (moving.isEmpty()) {
483             return false;
484         }
485         mBubbles.removeAll(moving);
486         mBubbles.addAll(position + 1, moving);
487         return true;
488     }
489 
490     /**
491      * This applies a full sort and group pass to all existing bubbles. The bubbles are grouped
492      * by groupId. Each group is then sorted by the max(lastUpdated) time of it's bubbles. Bubbles
493      * within each group are then sorted by lastUpdated descending.
494      *
495      * @return true if the position of any bubbles changed as a result
496      */
repackAll()497     private boolean repackAll() {
498         if (DEBUG) {
499             Log.d(TAG, "repackAll()");
500         }
501         if (mBubbles.isEmpty()) {
502             return false;
503         }
504         Map<String, Long> groupLastActivity = new HashMap<>();
505         for (Bubble bubble : mBubbles) {
506             long maxSortKeyForGroup = groupLastActivity.getOrDefault(bubble.getGroupId(), 0L);
507             long sortKeyForBubble = sortKey(bubble);
508             if (sortKeyForBubble > maxSortKeyForGroup) {
509                 groupLastActivity.put(bubble.getGroupId(), sortKeyForBubble);
510             }
511         }
512 
513         // Sort groups by their most recently active bubble
514         List<String> groupsByMostRecentActivity =
515                 groupLastActivity.entrySet().stream()
516                         .sorted(GROUPS_BY_MAX_SORT_KEY_DESCENDING)
517                         .map(Map.Entry::getKey)
518                         .collect(toList());
519 
520         List<Bubble> repacked = new ArrayList<>(mBubbles.size());
521 
522         // For each group, add bubbles, freshest to oldest
523         for (String appId : groupsByMostRecentActivity) {
524             mBubbles.stream()
525                     .filter((b) -> b.getGroupId().equals(appId))
526                     .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
527                     .forEachOrdered(repacked::add);
528         }
529         if (repacked.equals(mBubbles)) {
530             return false;
531         }
532         mBubbles.clear();
533         mBubbles.addAll(repacked);
534         return true;
535     }
536 
maybeSendDeleteIntent(@ismissReason int reason, NotificationEntry entry)537     private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
538         if (reason == BubbleController.DISMISS_USER_GESTURE) {
539             Notification.BubbleMetadata bubbleMetadata = entry.getBubbleMetadata();
540             PendingIntent deleteIntent = bubbleMetadata != null
541                     ? bubbleMetadata.getDeleteIntent()
542                     : null;
543             if (deleteIntent != null) {
544                 try {
545                     deleteIntent.send();
546                 } catch (PendingIntent.CanceledException e) {
547                     Log.w(TAG, "Failed to send delete intent for bubble with key: " + entry.key);
548                 }
549             }
550         }
551     }
552 
onBubbleBlocked(NotificationEntry entry)553     private void onBubbleBlocked(NotificationEntry entry) {
554         final String blockedGroupId = Bubble.groupId(entry);
555         int selectedIndex = mBubbles.indexOf(mSelectedBubble);
556         for (Iterator<Bubble> i = mBubbles.iterator(); i.hasNext(); ) {
557             Bubble bubble = i.next();
558             if (bubble.getGroupId().equals(blockedGroupId)) {
559                 mStateChange.bubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
560                 i.remove();
561             }
562         }
563         if (mBubbles.isEmpty()) {
564             setExpandedInternal(false);
565             setSelectedBubbleInternal(null);
566         } else if (!mBubbles.contains(mSelectedBubble)) {
567             // choose a new one
568             int newIndex = Math.min(selectedIndex, mBubbles.size() - 1);
569             Bubble newSelected = mBubbles.get(newIndex);
570             setSelectedBubbleInternal(newSelected);
571         }
572         dispatchPendingChanges();
573     }
574 
indexForKey(String key)575     private int indexForKey(String key) {
576         for (int i = 0; i < mBubbles.size(); i++) {
577             Bubble bubble = mBubbles.get(i);
578             if (bubble.getKey().equals(key)) {
579                 return i;
580             }
581         }
582         return -1;
583     }
584 
585     /**
586      * The set of bubbles.
587      */
588     @VisibleForTesting(visibility = PRIVATE)
getBubbles()589     public List<Bubble> getBubbles() {
590         return Collections.unmodifiableList(mBubbles);
591     }
592 
593     @VisibleForTesting(visibility = PRIVATE)
getBubbleWithKey(String key)594     Bubble getBubbleWithKey(String key) {
595         for (int i = 0; i < mBubbles.size(); i++) {
596             Bubble bubble = mBubbles.get(i);
597             if (bubble.getKey().equals(key)) {
598                 return bubble;
599             }
600         }
601         return null;
602     }
603 
604     @VisibleForTesting(visibility = PRIVATE)
setTimeSource(TimeSource timeSource)605     void setTimeSource(TimeSource timeSource) {
606         mTimeSource = timeSource;
607     }
608 
setListener(Listener listener)609     public void setListener(Listener listener) {
610         mListener = listener;
611     }
612 
shouldAutoExpand(NotificationEntry entry)613     boolean shouldAutoExpand(NotificationEntry entry) {
614         Notification.BubbleMetadata metadata = entry.getBubbleMetadata();
615         return metadata != null && metadata.getAutoExpandBubble()
616                 && BubbleController.isForegroundApp(mContext, entry.notification.getPackageName());
617     }
618 }