/*
|
* Copyright (C) 2019 The Android Open Source Project
|
*
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
* you may not use this file except in compliance with the License.
|
* You may obtain a copy of the License at
|
*
|
* http://www.apache.org/licenses/LICENSE-2.0
|
*
|
* Unless required by applicable law or agreed to in writing, software
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
* See the License for the specific language governing permissions and
|
* limitations under the License.
|
*/
|
package com.android.systemui.bubbles;
|
|
import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
|
|
import static java.util.stream.Collectors.toList;
|
|
import android.app.Notification;
|
import android.app.PendingIntent;
|
import android.content.Context;
|
import android.service.notification.NotificationListenerService;
|
import android.service.notification.NotificationListenerService.RankingMap;
|
import android.util.Log;
|
import android.util.Pair;
|
|
import androidx.annotation.Nullable;
|
|
import com.android.internal.annotations.VisibleForTesting;
|
import com.android.systemui.bubbles.BubbleController.DismissReason;
|
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
|
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.Comparator;
|
import java.util.HashMap;
|
import java.util.Iterator;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
|
import javax.inject.Inject;
|
import javax.inject.Singleton;
|
|
/**
|
* Keeps track of active bubbles.
|
*/
|
@Singleton
|
public class BubbleData {
|
|
private static final String TAG = "BubbleData";
|
private static final boolean DEBUG = false;
|
|
private static final int MAX_BUBBLES = 5;
|
|
private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
|
Comparator.comparing(BubbleData::sortKey).reversed();
|
|
private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_MAX_SORT_KEY_DESCENDING =
|
Comparator.<Map.Entry<String, Long>, Long>comparing(Map.Entry::getValue).reversed();
|
|
/** Contains information about changes that have been made to the state of bubbles. */
|
static final class Update {
|
boolean expandedChanged;
|
boolean selectionChanged;
|
boolean orderChanged;
|
boolean expanded;
|
@Nullable Bubble selectedBubble;
|
@Nullable Bubble addedBubble;
|
@Nullable Bubble updatedBubble;
|
// Pair with Bubble and @DismissReason Integer
|
final List<Pair<Bubble, Integer>> removedBubbles = new ArrayList<>();
|
|
// A read-only view of the bubbles list, changes there will be reflected here.
|
final List<Bubble> bubbles;
|
|
private Update(List<Bubble> bubbleOrder) {
|
bubbles = Collections.unmodifiableList(bubbleOrder);
|
}
|
|
boolean anythingChanged() {
|
return expandedChanged
|
|| selectionChanged
|
|| addedBubble != null
|
|| updatedBubble != null
|
|| !removedBubbles.isEmpty()
|
|| orderChanged;
|
}
|
|
void bubbleRemoved(Bubble bubbleToRemove, @DismissReason int reason) {
|
removedBubbles.add(new Pair<>(bubbleToRemove, reason));
|
}
|
}
|
|
/**
|
* This interface reports changes to the state and appearance of bubbles which should be applied
|
* as necessary to the UI.
|
*/
|
interface Listener {
|
/** Reports changes have have occurred as a result of the most recent operation. */
|
void applyUpdate(Update update);
|
}
|
|
interface TimeSource {
|
long currentTimeMillis();
|
}
|
|
private final Context mContext;
|
private final List<Bubble> mBubbles;
|
private Bubble mSelectedBubble;
|
private boolean mExpanded;
|
|
// State tracked during an operation -- keeps track of what listener events to dispatch.
|
private Update mStateChange;
|
|
private NotificationListenerService.Ranking mTmpRanking;
|
|
private TimeSource mTimeSource = System::currentTimeMillis;
|
|
@Nullable
|
private Listener mListener;
|
|
@Inject
|
public BubbleData(Context context) {
|
mContext = context;
|
mBubbles = new ArrayList<>();
|
mStateChange = new Update(mBubbles);
|
}
|
|
public boolean hasBubbles() {
|
return !mBubbles.isEmpty();
|
}
|
|
public boolean isExpanded() {
|
return mExpanded;
|
}
|
|
public boolean hasBubbleWithKey(String key) {
|
return getBubbleWithKey(key) != null;
|
}
|
|
@Nullable
|
public Bubble getSelectedBubble() {
|
return mSelectedBubble;
|
}
|
|
public void setExpanded(boolean expanded) {
|
if (DEBUG) {
|
Log.d(TAG, "setExpanded: " + expanded);
|
}
|
setExpandedInternal(expanded);
|
dispatchPendingChanges();
|
}
|
|
public void setSelectedBubble(Bubble bubble) {
|
if (DEBUG) {
|
Log.d(TAG, "setSelectedBubble: " + bubble);
|
}
|
setSelectedBubbleInternal(bubble);
|
dispatchPendingChanges();
|
}
|
|
public void notificationEntryUpdated(NotificationEntry entry) {
|
if (DEBUG) {
|
Log.d(TAG, "notificationEntryUpdated: " + entry);
|
}
|
Bubble bubble = getBubbleWithKey(entry.key);
|
if (bubble == null) {
|
// Create a new bubble
|
bubble = new Bubble(mContext, entry, this::onBubbleBlocked);
|
doAdd(bubble);
|
trim();
|
} else {
|
// Updates an existing bubble
|
bubble.setEntry(entry);
|
doUpdate(bubble);
|
}
|
if (shouldAutoExpand(entry)) {
|
setSelectedBubbleInternal(bubble);
|
if (!mExpanded) {
|
setExpandedInternal(true);
|
}
|
} else if (mSelectedBubble == null) {
|
setSelectedBubbleInternal(bubble);
|
}
|
dispatchPendingChanges();
|
}
|
|
public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
|
if (DEBUG) {
|
Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
|
}
|
doRemove(entry.key, reason);
|
dispatchPendingChanges();
|
}
|
|
/**
|
* Called when NotificationListener has received adjusted notification rank and reapplied
|
* filtering and sorting. This is used to dismiss any bubbles which should no longer be shown
|
* due to changes in permissions on the notification channel or the global setting.
|
*
|
* @param rankingMap the updated ranking map from NotificationListenerService
|
*/
|
public void notificationRankingUpdated(RankingMap rankingMap) {
|
if (mTmpRanking == null) {
|
mTmpRanking = new NotificationListenerService.Ranking();
|
}
|
|
String[] orderedKeys = rankingMap.getOrderedKeys();
|
for (int i = 0; i < orderedKeys.length; i++) {
|
String key = orderedKeys[i];
|
if (hasBubbleWithKey(key)) {
|
rankingMap.getRanking(key, mTmpRanking);
|
if (!mTmpRanking.canBubble()) {
|
doRemove(key, BubbleController.DISMISS_BLOCKED);
|
}
|
}
|
}
|
dispatchPendingChanges();
|
}
|
|
private void doAdd(Bubble bubble) {
|
if (DEBUG) {
|
Log.d(TAG, "doAdd: " + bubble);
|
}
|
int minInsertPoint = 0;
|
boolean newGroup = !hasBubbleWithGroupId(bubble.getGroupId());
|
if (isExpanded()) {
|
// first bubble of a group goes to the beginning, otherwise within the existing group
|
minInsertPoint = newGroup ? 0 : findFirstIndexForGroup(bubble.getGroupId());
|
}
|
if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
|
mStateChange.orderChanged = true;
|
}
|
mStateChange.addedBubble = bubble;
|
if (!isExpanded()) {
|
mStateChange.orderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
|
// Top bubble becomes selected.
|
setSelectedBubbleInternal(mBubbles.get(0));
|
}
|
}
|
|
private void trim() {
|
if (mBubbles.size() > MAX_BUBBLES) {
|
mBubbles.stream()
|
// sort oldest first (ascending lastActivity)
|
.sorted(Comparator.comparingLong(Bubble::getLastActivity))
|
// skip the selected bubble
|
.filter((b) -> !b.equals(mSelectedBubble))
|
.findFirst()
|
.ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
|
}
|
}
|
|
private void doUpdate(Bubble bubble) {
|
if (DEBUG) {
|
Log.d(TAG, "doUpdate: " + bubble);
|
}
|
mStateChange.updatedBubble = bubble;
|
if (!isExpanded()) {
|
// while collapsed, update causes re-pack
|
int prevPos = mBubbles.indexOf(bubble);
|
mBubbles.remove(bubble);
|
int newPos = insertBubble(0, bubble);
|
if (prevPos != newPos) {
|
packGroup(newPos);
|
mStateChange.orderChanged = true;
|
}
|
setSelectedBubbleInternal(mBubbles.get(0));
|
}
|
}
|
|
private void doRemove(String key, @DismissReason int reason) {
|
int indexToRemove = indexForKey(key);
|
if (indexToRemove == -1) {
|
return;
|
}
|
Bubble bubbleToRemove = mBubbles.get(indexToRemove);
|
if (mBubbles.size() == 1) {
|
// Going to become empty, handle specially.
|
setExpandedInternal(false);
|
setSelectedBubbleInternal(null);
|
}
|
if (indexToRemove < mBubbles.size() - 1) {
|
// Removing anything but the last bubble means positions will change.
|
mStateChange.orderChanged = true;
|
}
|
mBubbles.remove(indexToRemove);
|
mStateChange.bubbleRemoved(bubbleToRemove, reason);
|
if (!isExpanded()) {
|
mStateChange.orderChanged |= repackAll();
|
}
|
|
// Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
|
if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
|
// Move selection to the new bubble at the same position.
|
int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
|
Bubble newSelected = mBubbles.get(newIndex);
|
setSelectedBubbleInternal(newSelected);
|
}
|
bubbleToRemove.setDismissed();
|
maybeSendDeleteIntent(reason, bubbleToRemove.entry);
|
}
|
|
public void dismissAll(@DismissReason int reason) {
|
if (DEBUG) {
|
Log.d(TAG, "dismissAll: reason=" + reason);
|
}
|
if (mBubbles.isEmpty()) {
|
return;
|
}
|
setExpandedInternal(false);
|
setSelectedBubbleInternal(null);
|
while (!mBubbles.isEmpty()) {
|
Bubble bubble = mBubbles.remove(0);
|
bubble.setDismissed();
|
maybeSendDeleteIntent(reason, bubble.entry);
|
mStateChange.bubbleRemoved(bubble, reason);
|
}
|
dispatchPendingChanges();
|
}
|
|
private void dispatchPendingChanges() {
|
if (mListener != null && mStateChange.anythingChanged()) {
|
mListener.applyUpdate(mStateChange);
|
}
|
mStateChange = new Update(mBubbles);
|
}
|
|
/**
|
* Requests a change to the selected bubble.
|
*
|
* @param bubble the new selected bubble
|
*/
|
private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
|
if (DEBUG) {
|
Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
|
}
|
if (Objects.equals(bubble, mSelectedBubble)) {
|
return;
|
}
|
if (bubble != null && !mBubbles.contains(bubble)) {
|
Log.e(TAG, "Cannot select bubble which doesn't exist!"
|
+ " (" + bubble + ") bubbles=" + mBubbles);
|
return;
|
}
|
if (mExpanded && bubble != null) {
|
bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
|
}
|
mSelectedBubble = bubble;
|
mStateChange.selectedBubble = bubble;
|
mStateChange.selectionChanged = true;
|
}
|
|
/**
|
* Requests a change to the expanded state.
|
*
|
* @param shouldExpand the new requested state
|
*/
|
private void setExpandedInternal(boolean shouldExpand) {
|
if (DEBUG) {
|
Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
|
}
|
if (mExpanded == shouldExpand) {
|
return;
|
}
|
if (shouldExpand) {
|
if (mBubbles.isEmpty()) {
|
Log.e(TAG, "Attempt to expand stack when empty!");
|
return;
|
}
|
if (mSelectedBubble == null) {
|
Log.e(TAG, "Attempt to expand stack without selected bubble!");
|
return;
|
}
|
mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
|
mStateChange.orderChanged |= repackAll();
|
} else if (!mBubbles.isEmpty()) {
|
// Apply ordering and grouping rules from expanded -> collapsed, then save
|
// the result.
|
mStateChange.orderChanged |= repackAll();
|
// Save the state which should be returned to when expanded (with no other changes)
|
|
if (mBubbles.indexOf(mSelectedBubble) > 0) {
|
// Move the selected bubble to the top while collapsed.
|
if (!mSelectedBubble.isOngoing() && mBubbles.get(0).isOngoing()) {
|
// The selected bubble cannot be raised to the first position because
|
// there is an ongoing bubble there. Instead, force the top ongoing bubble
|
// to become selected.
|
setSelectedBubbleInternal(mBubbles.get(0));
|
} else {
|
// Raise the selected bubble (and it's group) up to the front so the selected
|
// bubble remains on top.
|
mBubbles.remove(mSelectedBubble);
|
mBubbles.add(0, mSelectedBubble);
|
packGroup(0);
|
}
|
}
|
}
|
mExpanded = shouldExpand;
|
mStateChange.expanded = shouldExpand;
|
mStateChange.expandedChanged = true;
|
}
|
|
private static long sortKey(Bubble bubble) {
|
long key = bubble.getLastUpdateTime();
|
if (bubble.isOngoing()) {
|
// Set 2nd highest bit (signed long int), to partition between ongoing and regular
|
key |= 0x4000000000000000L;
|
}
|
return key;
|
}
|
|
/**
|
* Locates and inserts the bubble into a sorted position. The is inserted
|
* based on sort key, groupId is not considered. A call to {@link #packGroup(int)} may be
|
* required to keep grouping intact.
|
*
|
* @param minPosition the first insert point to consider
|
* @param newBubble the bubble to insert
|
* @return the position where the bubble was inserted
|
*/
|
private int insertBubble(int minPosition, Bubble newBubble) {
|
long newBubbleSortKey = sortKey(newBubble);
|
String previousGroupId = null;
|
|
for (int pos = minPosition; pos < mBubbles.size(); pos++) {
|
Bubble bubbleAtPos = mBubbles.get(pos);
|
String groupIdAtPos = bubbleAtPos.getGroupId();
|
boolean atStartOfGroup = !groupIdAtPos.equals(previousGroupId);
|
|
if (atStartOfGroup && newBubbleSortKey > sortKey(bubbleAtPos)) {
|
// Insert before the start of first group which has older bubbles.
|
mBubbles.add(pos, newBubble);
|
return pos;
|
}
|
previousGroupId = groupIdAtPos;
|
}
|
mBubbles.add(newBubble);
|
return mBubbles.size() - 1;
|
}
|
|
private boolean hasBubbleWithGroupId(String groupId) {
|
return mBubbles.stream().anyMatch(b -> b.getGroupId().equals(groupId));
|
}
|
|
private int findFirstIndexForGroup(String appId) {
|
for (int i = 0; i < mBubbles.size(); i++) {
|
Bubble bubbleAtPos = mBubbles.get(i);
|
if (bubbleAtPos.getGroupId().equals(appId)) {
|
return i;
|
}
|
}
|
return 0;
|
}
|
|
/**
|
* Starting at the given position, moves all bubbles with the same group id to follow. Bubbles
|
* at positions lower than {@code position} are unchanged. Relative order within the group
|
* unchanged. Relative order of any other bubbles are also unchanged.
|
*
|
* @param position the position of the first bubble for the group
|
* @return true if the position of any bubbles has changed as a result
|
*/
|
private boolean packGroup(int position) {
|
if (DEBUG) {
|
Log.d(TAG, "packGroup: position=" + position);
|
}
|
Bubble groupStart = mBubbles.get(position);
|
final String groupAppId = groupStart.getGroupId();
|
List<Bubble> moving = new ArrayList<>();
|
|
// Walk backward, collect bubbles within the group
|
for (int i = mBubbles.size() - 1; i > position; i--) {
|
if (mBubbles.get(i).getGroupId().equals(groupAppId)) {
|
moving.add(0, mBubbles.get(i));
|
}
|
}
|
if (moving.isEmpty()) {
|
return false;
|
}
|
mBubbles.removeAll(moving);
|
mBubbles.addAll(position + 1, moving);
|
return true;
|
}
|
|
/**
|
* This applies a full sort and group pass to all existing bubbles. The bubbles are grouped
|
* by groupId. Each group is then sorted by the max(lastUpdated) time of it's bubbles. Bubbles
|
* within each group are then sorted by lastUpdated descending.
|
*
|
* @return true if the position of any bubbles changed as a result
|
*/
|
private boolean repackAll() {
|
if (DEBUG) {
|
Log.d(TAG, "repackAll()");
|
}
|
if (mBubbles.isEmpty()) {
|
return false;
|
}
|
Map<String, Long> groupLastActivity = new HashMap<>();
|
for (Bubble bubble : mBubbles) {
|
long maxSortKeyForGroup = groupLastActivity.getOrDefault(bubble.getGroupId(), 0L);
|
long sortKeyForBubble = sortKey(bubble);
|
if (sortKeyForBubble > maxSortKeyForGroup) {
|
groupLastActivity.put(bubble.getGroupId(), sortKeyForBubble);
|
}
|
}
|
|
// Sort groups by their most recently active bubble
|
List<String> groupsByMostRecentActivity =
|
groupLastActivity.entrySet().stream()
|
.sorted(GROUPS_BY_MAX_SORT_KEY_DESCENDING)
|
.map(Map.Entry::getKey)
|
.collect(toList());
|
|
List<Bubble> repacked = new ArrayList<>(mBubbles.size());
|
|
// For each group, add bubbles, freshest to oldest
|
for (String appId : groupsByMostRecentActivity) {
|
mBubbles.stream()
|
.filter((b) -> b.getGroupId().equals(appId))
|
.sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
|
.forEachOrdered(repacked::add);
|
}
|
if (repacked.equals(mBubbles)) {
|
return false;
|
}
|
mBubbles.clear();
|
mBubbles.addAll(repacked);
|
return true;
|
}
|
|
private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
|
if (reason == BubbleController.DISMISS_USER_GESTURE) {
|
Notification.BubbleMetadata bubbleMetadata = entry.getBubbleMetadata();
|
PendingIntent deleteIntent = bubbleMetadata != null
|
? bubbleMetadata.getDeleteIntent()
|
: null;
|
if (deleteIntent != null) {
|
try {
|
deleteIntent.send();
|
} catch (PendingIntent.CanceledException e) {
|
Log.w(TAG, "Failed to send delete intent for bubble with key: " + entry.key);
|
}
|
}
|
}
|
}
|
|
private void onBubbleBlocked(NotificationEntry entry) {
|
final String blockedGroupId = Bubble.groupId(entry);
|
int selectedIndex = mBubbles.indexOf(mSelectedBubble);
|
for (Iterator<Bubble> i = mBubbles.iterator(); i.hasNext(); ) {
|
Bubble bubble = i.next();
|
if (bubble.getGroupId().equals(blockedGroupId)) {
|
mStateChange.bubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
|
i.remove();
|
}
|
}
|
if (mBubbles.isEmpty()) {
|
setExpandedInternal(false);
|
setSelectedBubbleInternal(null);
|
} else if (!mBubbles.contains(mSelectedBubble)) {
|
// choose a new one
|
int newIndex = Math.min(selectedIndex, mBubbles.size() - 1);
|
Bubble newSelected = mBubbles.get(newIndex);
|
setSelectedBubbleInternal(newSelected);
|
}
|
dispatchPendingChanges();
|
}
|
|
private int indexForKey(String key) {
|
for (int i = 0; i < mBubbles.size(); i++) {
|
Bubble bubble = mBubbles.get(i);
|
if (bubble.getKey().equals(key)) {
|
return i;
|
}
|
}
|
return -1;
|
}
|
|
/**
|
* The set of bubbles.
|
*/
|
@VisibleForTesting(visibility = PRIVATE)
|
public List<Bubble> getBubbles() {
|
return Collections.unmodifiableList(mBubbles);
|
}
|
|
@VisibleForTesting(visibility = PRIVATE)
|
Bubble getBubbleWithKey(String key) {
|
for (int i = 0; i < mBubbles.size(); i++) {
|
Bubble bubble = mBubbles.get(i);
|
if (bubble.getKey().equals(key)) {
|
return bubble;
|
}
|
}
|
return null;
|
}
|
|
@VisibleForTesting(visibility = PRIVATE)
|
void setTimeSource(TimeSource timeSource) {
|
mTimeSource = timeSource;
|
}
|
|
public void setListener(Listener listener) {
|
mListener = listener;
|
}
|
|
boolean shouldAutoExpand(NotificationEntry entry) {
|
Notification.BubbleMetadata metadata = entry.getBubbleMetadata();
|
return metadata != null && metadata.getAutoExpandBubble()
|
&& BubbleController.isForegroundApp(mContext, entry.notification.getPackageName());
|
}
|
}
|