Merge "Include bubble changes in ranking & move flagging to BubbleExtractor" into rvc-dev am: 583092c980

Change-Id: Idad9adbfdd9374cbf908d86d385a3c3d0ba25037
This commit is contained in:
Automerger Merge Worker
2020-03-16 22:38:53 +00:00
15 changed files with 1039 additions and 417 deletions

View File

@@ -55,7 +55,6 @@ import android.os.ServiceManager;
import android.os.UserHandle;
import android.util.ArrayMap;
import android.util.Log;
import android.util.Slog;
import android.widget.RemoteViews;
import com.android.internal.annotations.GuardedBy;
@@ -1570,6 +1569,7 @@ public abstract class NotificationListenerService extends Service {
private boolean mVisuallyInterruptive;
private boolean mIsConversation;
private ShortcutInfo mShortcutInfo;
private boolean mIsBubble;
private static final int PARCEL_VERSION = 2;
@@ -1604,6 +1604,7 @@ public abstract class NotificationListenerService extends Service {
out.writeBoolean(mVisuallyInterruptive);
out.writeBoolean(mIsConversation);
out.writeParcelable(mShortcutInfo, flags);
out.writeBoolean(mIsBubble);
}
/** @hide */
@@ -1639,6 +1640,7 @@ public abstract class NotificationListenerService extends Service {
mVisuallyInterruptive = in.readBoolean();
mIsConversation = in.readBoolean();
mShortcutInfo = in.readParcelable(cl);
mIsBubble = in.readBoolean();
}
@@ -1843,6 +1845,14 @@ public abstract class NotificationListenerService extends Service {
return mIsConversation;
}
/**
* Returns whether this notification is actively a bubble.
* @hide
*/
public boolean isBubble() {
return mIsBubble;
}
/**
* @hide
*/
@@ -1862,7 +1872,8 @@ public abstract class NotificationListenerService extends Service {
int userSentiment, boolean hidden, long lastAudiblyAlertedMs,
boolean noisy, ArrayList<Notification.Action> smartActions,
ArrayList<CharSequence> smartReplies, boolean canBubble,
boolean visuallyInterruptive, boolean isConversation, ShortcutInfo shortcutInfo) {
boolean visuallyInterruptive, boolean isConversation, ShortcutInfo shortcutInfo,
boolean isBubble) {
mKey = key;
mRank = rank;
mIsAmbient = importance < NotificationManager.IMPORTANCE_LOW;
@@ -1886,6 +1897,7 @@ public abstract class NotificationListenerService extends Service {
mVisuallyInterruptive = visuallyInterruptive;
mIsConversation = isConversation;
mShortcutInfo = shortcutInfo;
mIsBubble = isBubble;
}
/**
@@ -1913,7 +1925,8 @@ public abstract class NotificationListenerService extends Service {
other.mCanBubble,
other.mVisuallyInterruptive,
other.mIsConversation,
other.mShortcutInfo);
other.mShortcutInfo,
other.mIsBubble);
}
/**
@@ -1970,7 +1983,8 @@ public abstract class NotificationListenerService extends Service {
&& Objects.equals(mIsConversation, other.mIsConversation)
// Shortcutinfo doesn't have equals either; use id
&& Objects.equals((mShortcutInfo == null ? 0 : mShortcutInfo.getId()),
(other.mShortcutInfo == null ? 0 : other.mShortcutInfo.getId()));
(other.mShortcutInfo == null ? 0 : other.mShortcutInfo.getId()))
&& Objects.equals(mIsBubble, other.mIsBubble);
}
}

View File

@@ -52,6 +52,7 @@ import android.content.res.Configuration;
import android.graphics.Rect;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationListenerService.RankingMap;
import android.service.notification.ZenModeConfig;
import android.util.ArraySet;
@@ -146,13 +147,15 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
private BubbleData mBubbleData;
@Nullable private BubbleStackView mStackView;
private BubbleIconFactory mBubbleIconFactory;
private int mMaxBubbles;
// Tracks the id of the current (foreground) user.
private int mCurrentUserId;
// Saves notification keys of active bubbles when users are switched.
private final SparseSetArray<String> mSavedBubbleKeysPerUser;
// Used when ranking updates occur and we check if things should bubble / unbubble
private NotificationListenerService.Ranking mTmpRanking;
// Saves notification keys of user created "fake" bubbles so that we can allow notifications
// like these to bubble by default. Doesn't persist across reboots, not a long-term solution.
private final HashSet<String> mUserCreatedBubbles;
@@ -338,7 +341,6 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
configurationController.addCallback(this /* configurationListener */);
mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
mBubbleData = data;
mBubbleData.setListener(mBubbleDataListener);
mBubbleData.setSuppressionChangedListener(new NotificationSuppressionChangedListener() {
@@ -939,9 +941,29 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
}
}
/**
* Called when NotificationListener has received adjusted notification rank and reapplied
* filtering and sorting. This is used to dismiss or create bubbles based on changes in
* permissions on the notification channel or the global setting.
*
* @param rankingMap the updated ranking map from NotificationListenerService
*/
private void onRankingUpdated(RankingMap rankingMap) {
// Forward to BubbleData to block any bubbles which should no longer be shown
mBubbleData.notificationRankingUpdated(rankingMap);
if (mTmpRanking == null) {
mTmpRanking = new NotificationListenerService.Ranking();
}
String[] orderedKeys = rankingMap.getOrderedKeys();
for (int i = 0; i < orderedKeys.length; i++) {
String key = orderedKeys[i];
NotificationEntry entry = mNotificationEntryManager.getPendingOrActiveNotif(key);
rankingMap.getRanking(key, mTmpRanking);
if (mBubbleData.hasBubbleWithKey(key) && !mTmpRanking.canBubble()) {
mBubbleData.notificationEntryRemoved(entry, BubbleController.DISMISS_BLOCKED);
} else if (entry != null && mTmpRanking.isBubble()) {
entry.setFlagBubble(true);
onEntryUpdated(entry);
}
}
}
@SuppressWarnings("FieldCanBeLocal")

View File

@@ -26,7 +26,6 @@ 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;
@@ -297,31 +296,6 @@ public class BubbleData {
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();
}
/**
* Adds a group key indicating that the summary for this group should be suppressed.
*

View File

@@ -206,7 +206,8 @@ public class NotificationListener extends NotificationListenerWithPlugins {
false,
false,
false,
null
null,
false
);
}
return ranking;

View File

@@ -55,6 +55,7 @@ public class RankingBuilder {
private boolean mIsVisuallyInterruptive = false;
private boolean mIsConversation = false;
private ShortcutInfo mShortcutInfo = null;
private boolean mIsBubble = false;
public RankingBuilder() {
}
@@ -82,6 +83,7 @@ public class RankingBuilder {
mIsVisuallyInterruptive = ranking.visuallyInterruptive();
mIsConversation = ranking.isConversation();
mShortcutInfo = ranking.getShortcutInfo();
mIsBubble = ranking.isBubble();
}
public Ranking build() {
@@ -108,7 +110,8 @@ public class RankingBuilder {
mCanBubble,
mIsVisuallyInterruptive,
mIsConversation,
mShortcutInfo);
mShortcutInfo,
mIsBubble);
return ranking;
}

View File

@@ -143,7 +143,7 @@ public class NotificationEntryManagerTest extends SysuiTestCase {
IMPORTANCE_DEFAULT,
null, null,
null, null, null, true, sentiment, false, -1, false, null, null, false, false,
false, null);
false, null, false);
return true;
}).when(mRankingMap).getRanking(eq(key), any(Ranking.class));
}
@@ -162,7 +162,7 @@ public class NotificationEntryManagerTest extends SysuiTestCase {
null, null,
null, null, null, true,
Ranking.USER_SENTIMENT_NEUTRAL, false, -1,
false, smartActions, null, false, false, false, null);
false, smartActions, null, false, false, false, null, false);
return true;
}).when(mRankingMap).getRanking(eq(key), any(Ranking.class));
}

View File

@@ -284,7 +284,8 @@ public class NotificationEntryManagerInflationTest extends SysuiTestCase {
false,
false,
false,
null);
null,
false);
mRankingMap = new NotificationListenerService.RankingMap(new Ranking[] {ranking});
TestableLooper.get(this).processAllMessages();

View File

@@ -15,9 +15,27 @@
*/
package com.android.server.notification;
import static android.app.Notification.CATEGORY_CALL;
import static android.app.Notification.FLAG_BUBBLE;
import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING;
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE;
import android.app.ActivityManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Person;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.util.Slog;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.FrameworkStatsLog;
import java.util.ArrayList;
/**
* Determines whether a bubble can be shown for this notification
*/
@@ -25,10 +43,15 @@ public class BubbleExtractor implements NotificationSignalExtractor {
private static final String TAG = "BubbleExtractor";
private static final boolean DBG = false;
private BubbleChecker mBubbleChecker;
private RankingConfig mConfig;
private ActivityManager mActivityManager;
private Context mContext;
public void initialize(Context ctx, NotificationUsageStats usageStats) {
public void initialize(Context context, NotificationUsageStats usageStats) {
if (DBG) Slog.d(TAG, "Initializing " + getClass().getSimpleName() + ".");
mContext = context;
mActivityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
}
public RankingReconsideration process(NotificationRecord record) {
@@ -41,6 +64,12 @@ public class BubbleExtractor implements NotificationSignalExtractor {
if (DBG) Slog.d(TAG, "missing config");
return null;
}
if (mBubbleChecker == null) {
if (DBG) Slog.d(TAG, "missing bubble checker");
return null;
}
boolean appCanShowBubble =
mConfig.areBubblesAllowed(record.getSbn().getPackageName(), record.getSbn().getUid());
if (!mConfig.bubblesEnabled() || !appCanShowBubble) {
@@ -52,7 +81,12 @@ public class BubbleExtractor implements NotificationSignalExtractor {
record.setAllowBubble(appCanShowBubble);
}
}
final boolean applyFlag = mBubbleChecker.isNotificationAppropriateToBubble(record);
if (applyFlag) {
record.getNotification().flags |= FLAG_BUBBLE;
} else {
record.getNotification().flags &= ~FLAG_BUBBLE;
}
return null;
}
@@ -64,4 +98,185 @@ public class BubbleExtractor implements NotificationSignalExtractor {
@Override
public void setZenHelper(ZenModeHelper helper) {
}
/**
* Expected to be called after {@link #setConfig(RankingConfig)} has occurred.
*/
void setShortcutHelper(ShortcutHelper helper) {
if (mConfig == null) {
if (DBG) Slog.d(TAG, "setting shortcut helper prior to setConfig");
return;
}
mBubbleChecker = new BubbleChecker(mContext, helper, mConfig, mActivityManager);
}
@VisibleForTesting
void setBubbleChecker(BubbleChecker checker) {
mBubbleChecker = checker;
}
/**
* Encapsulates special checks to see if a notification can be flagged as a bubble. This
* makes testing a bit easier.
*/
public static class BubbleChecker {
private ActivityManager mActivityManager;
private RankingConfig mRankingConfig;
private Context mContext;
private ShortcutHelper mShortcutHelper;
BubbleChecker(Context context, ShortcutHelper helper, RankingConfig config,
ActivityManager activityManager) {
mContext = context;
mActivityManager = activityManager;
mShortcutHelper = helper;
mRankingConfig = config;
}
/**
* @return whether the provided notification record is allowed to be represented as a
* bubble, accounting for user choice & policy.
*/
public boolean isNotificationAppropriateToBubble(NotificationRecord r) {
final String pkg = r.getSbn().getPackageName();
final int userId = r.getSbn().getUser().getIdentifier();
Notification notification = r.getNotification();
if (!canBubble(r, pkg, userId)) {
// no log: canBubble has its own
return false;
}
if (mActivityManager.isLowRamDevice()) {
logBubbleError(r.getKey(), "low ram device");
return false;
}
// At this point the bubble must fulfill communication policy
// Communication always needs a person
ArrayList<Person> peopleList = notification.extras != null
? notification.extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST)
: null;
// Message style requires a person & it's not included in the list
boolean isMessageStyle = Notification.MessagingStyle.class.equals(
notification.getNotificationStyle());
if (!isMessageStyle && (peopleList == null || peopleList.isEmpty())) {
logBubbleError(r.getKey(), "Must have a person and be "
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
return false;
}
// Communication is a message or a call
boolean isCall = CATEGORY_CALL.equals(notification.category);
boolean hasForegroundService = (notification.flags & FLAG_FOREGROUND_SERVICE) != 0;
if (hasForegroundService && !isCall) {
logBubbleError(r.getKey(),
"foreground services must be Notification.CATEGORY_CALL to bubble");
return false;
}
if (isMessageStyle) {
return true;
} else if (isCall) {
if (hasForegroundService) {
return true;
}
logBubbleError(r.getKey(), "calls require foreground service");
return false;
}
logBubbleError(r.getKey(), "Must be "
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
return false;
}
/**
* @return whether the user has enabled the provided notification to bubble, does not
* account for policy.
*/
@VisibleForTesting
boolean canBubble(NotificationRecord r, String pkg, int userId) {
Notification notification = r.getNotification();
Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
if (metadata == null) {
// no log: no need to inform dev if they didn't attach bubble metadata
return false;
}
if (!mRankingConfig.bubblesEnabled()) {
logBubbleError(r.getKey(), "bubbles disabled for user: " + userId);
return false;
}
if (!mRankingConfig.areBubblesAllowed(pkg, userId)) {
logBubbleError(r.getKey(),
"bubbles for package: " + pkg + " disabled for user: " + userId);
return false;
}
if (!r.getChannel().canBubble()) {
logBubbleError(r.getKey(),
"bubbles for channel " + r.getChannel().getId() + " disabled");
return false;
}
String shortcutId = metadata.getShortcutId();
boolean shortcutValid = shortcutId != null
&& mShortcutHelper.hasValidShortcutInfo(shortcutId, pkg, r.getUser());
if (metadata.getBubbleIntent() == null && !shortcutValid) {
// Should have a shortcut if intent is null
logBubbleError(r.getKey(),
"couldn't find valid shortcut for bubble with shortcutId: " + shortcutId);
return false;
}
if (shortcutValid) {
return true;
}
// no log: canLaunch method has the failure log
return canLaunchInActivityView(mContext, metadata.getBubbleIntent(), pkg);
}
/**
* Whether an intent is properly configured to display in an {@link
* android.app.ActivityView}.
*
* @param context the context to use.
* @param pendingIntent the pending intent of the bubble.
* @param packageName the notification package name for this bubble.
*/
// Keep checks in sync with BubbleController#canLaunchInActivityView.
@VisibleForTesting
protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
String packageName) {
if (pendingIntent == null) {
Slog.w(TAG, "Unable to create bubble -- no intent");
return false;
}
Intent intent = pendingIntent.getIntent();
ActivityInfo info = intent != null
? intent.resolveActivityInfo(context.getPackageManager(), 0)
: null;
if (info == null) {
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED,
packageName,
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING);
Slog.w(TAG, "Unable to send as bubble -- couldn't find activity info for intent: "
+ intent);
return false;
}
if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED,
packageName,
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE);
Slog.w(TAG, "Unable to send as bubble -- activity is not resizable for intent: "
+ intent);
return false;
}
return true;
}
private void logBubbleError(String key, String failureMessage) {
if (DBG) {
Slog.w(TAG, "Bubble notification: " + key + " failed: " + failureMessage);
}
}
}
}

View File

@@ -17,7 +17,6 @@
package com.android.server.notification;
import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
import static android.app.Notification.CATEGORY_CALL;
import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY;
import static android.app.Notification.FLAG_BUBBLE;
import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
@@ -51,9 +50,6 @@ import static android.content.Context.BIND_ALLOW_WHITELIST_MANAGEMENT;
import static android.content.Context.BIND_AUTO_CREATE;
import static android.content.Context.BIND_FOREGROUND_SERVICE;
import static android.content.Context.BIND_NOT_PERCEPTIBLE;
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED;
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
import static android.content.pm.PackageManager.FEATURE_LEANBACK;
import static android.content.pm.PackageManager.FEATURE_TELEVISION;
import static android.content.pm.PackageManager.MATCH_ALL;
@@ -93,8 +89,6 @@ import static android.service.notification.NotificationListenerService.TRIM_FULL
import static android.service.notification.NotificationListenerService.TRIM_LIGHT;
import static android.view.WindowManager.LayoutParams.TYPE_TOAST;
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING;
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE;
import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_CHANNEL_GROUP_PREFERENCES;
import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_CHANNEL_PREFERENCES;
import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_PREFERENCES;
@@ -131,8 +125,6 @@ import android.app.NotificationHistory.HistoricalNotification;
import android.app.NotificationManager;
import android.app.NotificationManager.Policy;
import android.app.PendingIntent;
import android.app.Person;
import android.app.RemoteInput;
import android.app.StatsManager;
import android.app.StatusBarManager;
import android.app.UriGrantsManager;
@@ -152,7 +144,6 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.IPackageManager;
import android.content.pm.LauncherApps;
@@ -160,7 +151,6 @@ import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.PackageManagerInternal;
import android.content.pm.ParceledListSlice;
import android.content.pm.ShortcutInfo;
import android.content.pm.UserInfo;
import android.content.res.Resources;
import android.database.ContentObserver;
@@ -251,7 +241,6 @@ import com.android.internal.util.ArrayUtils;
import com.android.internal.util.CollectionUtils;
import com.android.internal.util.DumpUtils;
import com.android.internal.util.FastXmlSerializer;
import com.android.internal.util.FrameworkStatsLog;
import com.android.internal.util.Preconditions;
import com.android.internal.util.XmlUtils;
import com.android.internal.util.function.TriPredicate;
@@ -296,7 +285,6 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map.Entry;
@@ -421,7 +409,7 @@ public class NotificationManagerService extends SystemService {
private RoleObserver mRoleObserver;
private UserManager mUm;
private IPlatformCompat mPlatformCompat;
private LauncherApps mLauncherAppsService;
private ShortcutHelper mShortcutHelper;
final IBinder mForegroundToken = new Binder();
private WorkerHandler mHandler;
@@ -497,7 +485,8 @@ public class NotificationManagerService extends SystemService {
"allow-secure-notifications-on-lockscreen";
private static final String LOCKSCREEN_ALLOW_SECURE_NOTIFICATIONS_VALUE = "value";
private RankingHelper mRankingHelper;
@VisibleForTesting
RankingHelper mRankingHelper;
@VisibleForTesting
PreferencesHelper mPreferencesHelper;
@@ -1203,13 +1192,30 @@ public class NotificationManagerService extends SystemService {
@Override
public void onNotificationBubbleChanged(String key, boolean isBubble) {
String pkg;
synchronized (mNotificationLock) {
NotificationRecord r = mNotificationsByKey.get(key);
pkg = r != null && r.getSbn() != null ? r.getSbn().getPackageName() : null;
}
boolean isAppForeground = pkg != null
&& mActivityManager.getPackageImportance(pkg) == IMPORTANCE_FOREGROUND;
synchronized (mNotificationLock) {
NotificationRecord r = mNotificationsByKey.get(key);
if (r != null) {
final StatusBarNotification n = r.getSbn();
final int callingUid = n.getUid();
final String pkg = n.getPackageName();
applyFlagBubble(r, pkg, callingUid, null /* oldEntry */, isBubble);
if (!isBubble) {
// This happens if the user has dismissed the bubble but the notification
// is still active in the shade, enqueuing would create a bubble since
// the notification is technically allowed. Flip the flag so that
// apps querying noMan will know that their notification is not showing
// as a bubble.
r.getNotification().flags &= ~FLAG_BUBBLE;
} else {
// Enqueue will trigger resort & if the flag is allowed to be true it'll
// be applied there.
r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
mHandler.post(new EnqueueNotificationRunnable(r.getUser().getIdentifier(),
r, isAppForeground));
}
}
}
}
@@ -1236,6 +1242,7 @@ public class NotificationManagerService extends SystemService {
flags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
}
data.setFlags(flags);
r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
mHandler.post(new EnqueueNotificationRunnable(r.getUser().getIdentifier(), r,
true /* isAppForeground */));
}
@@ -1615,80 +1622,6 @@ public class NotificationManagerService extends SystemService {
}
};
// Key: packageName Value: <shortcutId, notifId>
private HashMap<String, HashMap<String, String>> mActiveShortcutBubbles = new HashMap<>();
private boolean mLauncherAppsCallbackRegistered;
// Bubbles can be created based on a shortcut, we need to listen for changes to
// that shortcut so that we may update the bubble appropriately.
private final LauncherApps.Callback mLauncherAppsCallback = new LauncherApps.Callback() {
@Override
public void onPackageRemoved(String packageName, UserHandle user) {
}
@Override
public void onPackageAdded(String packageName, UserHandle user) {
}
@Override
public void onPackageChanged(String packageName, UserHandle user) {
}
@Override
public void onPackagesAvailable(String[] packageNames, UserHandle user,
boolean replacing) {
}
@Override
public void onPackagesUnavailable(String[] packageNames, UserHandle user,
boolean replacing) {
}
@Override
public void onShortcutsChanged(@NonNull String packageName,
@NonNull List<ShortcutInfo> shortcuts, @NonNull UserHandle user) {
HashMap<String, String> shortcutBubbles = mActiveShortcutBubbles.get(packageName);
boolean isAppForeground = packageName != null
&& mActivityManager.getPackageImportance(packageName) == IMPORTANCE_FOREGROUND;
ArrayList<String> bubbleKeysToRemove = new ArrayList<>();
if (shortcutBubbles != null) {
// If we can't find one of our bubbles in the shortcut list, that bubble needs
// to be removed.
for (String shortcutId : shortcutBubbles.keySet()) {
boolean foundShortcut = false;
for (int i = 0; i < shortcuts.size(); i++) {
if (shortcuts.get(i).getId().equals(shortcutId)) {
foundShortcut = true;
break;
}
}
if (!foundShortcut) {
bubbleKeysToRemove.add(shortcutBubbles.get(shortcutId));
}
}
}
// Do the removals
for (int i = 0; i < bubbleKeysToRemove.size(); i++) {
// update flag bubble
String bubbleKey = bubbleKeysToRemove.get(i);
synchronized (mNotificationLock) {
NotificationRecord r = mNotificationsByKey.get(bubbleKey);
if (r != null) {
final StatusBarNotification n = r.getSbn();
final int callingUid = n.getUid();
final String pkg = n.getPackageName();
applyFlagBubble(r, pkg, callingUid, null /* oldEntry */, isAppForeground);
mHandler.post(new EnqueueNotificationRunnable(user.getIdentifier(), r,
false /* isAppForeground */));
}
}
}
}
};
private final class SettingsObserver extends ContentObserver {
private final Uri NOTIFICATION_BADGING_URI
= Settings.Secure.getUriFor(Settings.Secure.NOTIFICATION_BADGING);
@@ -1783,8 +1716,8 @@ public class NotificationManagerService extends SystemService {
}
@VisibleForTesting
void setLauncherApps(LauncherApps launcherApps) {
mLauncherAppsService = launcherApps;
ShortcutHelper getShortcutHelper() {
return mShortcutHelper;
}
@VisibleForTesting
@@ -2334,8 +2267,13 @@ public class NotificationManagerService extends SystemService {
mRoleObserver = new RoleObserver(getContext().getSystemService(RoleManager.class),
mPackageManager, getContext().getMainExecutor());
mRoleObserver.init();
mLauncherAppsService =
LauncherApps launcherApps =
(LauncherApps) getContext().getSystemService(Context.LAUNCHER_APPS_SERVICE);
mShortcutHelper = new ShortcutHelper(launcherApps, mShortcutListener);
BubbleExtractor bubbsExtractor = mRankingHelper.findExtractor(BubbleExtractor.class);
if (bubbsExtractor != null) {
bubbsExtractor.setShortcutHelper(mShortcutHelper);
}
registerNotificationPreferencesPullers();
} else if (phase == SystemService.PHASE_THIRD_PARTY_APPS_CAN_START) {
// This observer will force an update when observe is called, causing us to
@@ -3478,7 +3416,7 @@ public class NotificationManagerService extends SystemService {
ArrayList<ConversationChannelWrapper> conversations =
mPreferencesHelper.getConversations(onlyImportant);
for (ConversationChannelWrapper conversation : conversations) {
conversation.setShortcutInfo(getShortcutInfo(
conversation.setShortcutInfo(mShortcutHelper.getShortcutInfo(
conversation.getNotificationChannel().getConversationId(),
conversation.getPkg(),
UserHandle.of(UserHandle.getUserId(conversation.getUid()))));
@@ -3501,7 +3439,7 @@ public class NotificationManagerService extends SystemService {
ArrayList<ConversationChannelWrapper> conversations =
mPreferencesHelper.getConversations(pkg, uid);
for (ConversationChannelWrapper conversation : conversations) {
conversation.setShortcutInfo(getShortcutInfo(
conversation.setShortcutInfo(mShortcutHelper.getShortcutInfo(
conversation.getNotificationChannel().getConversationId(),
pkg,
UserHandle.of(UserHandle.getUserId(uid))));
@@ -5672,7 +5610,7 @@ public class NotificationManagerService extends SystemService {
}
}
r.setShortcutInfo(getShortcutInfo(notification.getShortcutId(), pkg, user));
r.setShortcutInfo(mShortcutHelper.getShortcutInfo(notification.getShortcutId(), pkg, user));
if (!checkDisqualifyingFeatures(userId, notificationUid, id, tag, r,
r.getSbn().getOverrideGroupKey() != null)) {
@@ -5800,16 +5738,12 @@ public class NotificationManagerService extends SystemService {
}
/**
* Updates the flags for this notification to reflect whether it is a bubble or not. Some
* bubble specific flags only work if the app is foreground, this will strip those flags
* Some bubble specific flags only work if the app is foreground, this will strip those flags
* if the app wasn't foreground.
*/
private void updateNotificationBubbleFlags(NotificationRecord r, String pkg, int userId,
NotificationRecord oldRecord, boolean isAppForeground) {
Notification notification = r.getNotification();
applyFlagBubble(r, pkg, userId, oldRecord, true /* desiredFlag */);
private void updateNotificationBubbleFlags(NotificationRecord r, boolean isAppForeground) {
// Remove any bubble specific flags that only work when foregrounded
Notification notification = r.getNotification();
Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
if (!isAppForeground && metadata != null) {
int flags = metadata.getFlags();
@@ -5819,252 +5753,30 @@ public class NotificationManagerService extends SystemService {
}
}
/**
* Handles actually applying or removing {@link Notification#FLAG_BUBBLE}. Performs necessary
* checks for the provided record to see if it can actually be a bubble.
* Tracks shortcut based bubbles so that we can find out if they've changed or been removed.
*/
private void applyFlagBubble(NotificationRecord r, String pkg, int userId,
NotificationRecord oldRecord, boolean desiredFlag) {
boolean applyFlag = desiredFlag
&& isNotificationAppropriateToBubble(r, pkg, userId, oldRecord);
final String shortcutId = r.getNotification().getBubbleMetadata() != null
? r.getNotification().getBubbleMetadata().getShortcutId()
: null;
if (applyFlag) {
if (shortcutId != null) {
// Must track shortcut based bubbles in case the shortcut is removed
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
r.getSbn().getPackageName());
if (packageBubbles == null) {
packageBubbles = new HashMap<>();
private ShortcutHelper.ShortcutListener mShortcutListener =
new ShortcutHelper.ShortcutListener() {
@Override
public void onShortcutRemoved(String key) {
String packageName;
synchronized (mNotificationLock) {
NotificationRecord r = mNotificationsByKey.get(key);
packageName = r != null ? r.getSbn().getPackageName() : null;
}
boolean isAppForeground = packageName != null
&& mActivityManager.getPackageImportance(packageName)
== IMPORTANCE_FOREGROUND;
synchronized (mNotificationLock) {
NotificationRecord r = mNotificationsByKey.get(key);
if (r != null) {
// Enqueue will trigger resort & flag is updated that way.
r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
mHandler.post(
new NotificationManagerService.EnqueueNotificationRunnable(
r.getUser().getIdentifier(), r, isAppForeground));
}
}
}
packageBubbles.put(shortcutId, r.getKey());
mActiveShortcutBubbles.put(r.getSbn().getPackageName(), packageBubbles);
if (!mLauncherAppsCallbackRegistered) {
mLauncherAppsService.registerCallback(mLauncherAppsCallback, mHandler);
mLauncherAppsCallbackRegistered = true;
}
}
r.getNotification().flags |= FLAG_BUBBLE;
} else {
if (shortcutId != null) {
// No longer track shortcut
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
r.getSbn().getPackageName());
if (packageBubbles != null) {
packageBubbles.remove(shortcutId);
}
if (packageBubbles != null && packageBubbles.isEmpty()) {
mActiveShortcutBubbles.remove(r.getSbn().getPackageName());
}
if (mLauncherAppsCallbackRegistered && mActiveShortcutBubbles.isEmpty()) {
mLauncherAppsService.unregisterCallback(mLauncherAppsCallback);
mLauncherAppsCallbackRegistered = false;
}
}
r.getNotification().flags &= ~FLAG_BUBBLE;
}
}
/**
* @return whether the provided notification record is allowed to be represented as a bubble,
* accounting for user choice & policy.
*/
private boolean isNotificationAppropriateToBubble(NotificationRecord r, String pkg, int userId,
NotificationRecord oldRecord) {
Notification notification = r.getNotification();
if (!canBubble(r, pkg, userId)) {
// no log: canBubble has its own
return false;
}
if (mActivityManager.isLowRamDevice()) {
logBubbleError(r.getKey(), "low ram device");
return false;
}
if (oldRecord != null && (oldRecord.getNotification().flags & FLAG_BUBBLE) != 0) {
// This is an update to an active bubble
return true;
}
// At this point the bubble must fulfill communication policy
// Communication always needs a person
ArrayList<Person> peopleList = notification.extras != null
? notification.extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST)
: null;
// Message style requires a person & it's not included in the list
boolean isMessageStyle = Notification.MessagingStyle.class.equals(
notification.getNotificationStyle());
if (!isMessageStyle && (peopleList == null || peopleList.isEmpty())) {
logBubbleError(r.getKey(), "Must have a person and be "
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
return false;
}
// Communication is a message or a call
boolean isCall = CATEGORY_CALL.equals(notification.category);
boolean hasForegroundService = (notification.flags & FLAG_FOREGROUND_SERVICE) != 0;
if (hasForegroundService && !isCall) {
logBubbleError(r.getKey(),
"foreground services must be Notification.CATEGORY_CALL to bubble");
return false;
}
if (isMessageStyle) {
if (hasValidRemoteInput(notification)) {
return true;
}
logBubbleError(r.getKey(), "messages require valid remote input");
return false;
} else if (isCall) {
if (hasForegroundService) {
return true;
}
logBubbleError(r.getKey(), "calls require foreground service");
return false;
}
logBubbleError(r.getKey(), "Must be "
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
return false;
}
/**
* @return whether the user has enabled the provided notification to bubble, does not account
* for policy.
*/
private boolean canBubble(NotificationRecord r, String pkg, int userId) {
Notification notification = r.getNotification();
Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
if (metadata == null) {
// no log: no need to inform dev if they didn't attach bubble metadata
return false;
}
if (!mPreferencesHelper.bubblesEnabled()) {
logBubbleError(r.getKey(), "bubbles disabled for user: " + userId);
return false;
}
if (!mPreferencesHelper.areBubblesAllowed(pkg, userId)) {
logBubbleError(r.getKey(),
"bubbles for package: " + pkg + " disabled for user: " + userId);
return false;
}
if (!r.getChannel().canBubble()) {
logBubbleError(r.getKey(),
"bubbles for channel " + r.getChannel().getId() + " disabled");
return false;
}
String shortcutId = metadata.getShortcutId();
boolean shortcutValid = shortcutId != null
&& hasValidShortcutInfo(shortcutId, pkg, r.getUser());
if (metadata.getBubbleIntent() == null && !shortcutValid) {
// Should have a shortcut if intent is null
logBubbleError(r.getKey(), "couldn't find shortcutId for bubble: " + shortcutId);
return false;
}
if (shortcutValid) {
return true;
}
// no log: canLaunch method has the failure log
return canLaunchInActivityView(getContext(), metadata.getBubbleIntent(), pkg);
}
private boolean hasValidRemoteInput(Notification n) {
// Also check for inline reply
Notification.Action[] actions = n.actions;
if (actions != null) {
// Get the remote inputs
for (int i = 0; i < actions.length; i++) {
Notification.Action action = actions[i];
RemoteInput[] inputs = action.getRemoteInputs();
if (inputs != null && inputs.length > 0) {
return true;
}
}
}
return false;
}
private ShortcutInfo getShortcutInfo(String shortcutId, String packageName, UserHandle user) {
final long token = Binder.clearCallingIdentity();
try {
if (shortcutId == null || packageName == null || user == null) {
return null;
}
LauncherApps.ShortcutQuery query = new LauncherApps.ShortcutQuery();
if (packageName != null) {
query.setPackage(packageName);
}
if (shortcutId != null) {
query.setShortcutIds(Arrays.asList(shortcutId));
}
query.setQueryFlags(FLAG_MATCH_DYNAMIC | FLAG_MATCH_PINNED | FLAG_MATCH_CACHED);
List<ShortcutInfo> shortcuts = mLauncherAppsService.getShortcuts(query, user);
ShortcutInfo shortcutInfo = shortcuts != null && shortcuts.size() > 0
? shortcuts.get(0)
: null;
return shortcutInfo;
} finally {
Binder.restoreCallingIdentity(token);
}
}
private boolean hasValidShortcutInfo(String shortcutId, String packageName, UserHandle user) {
ShortcutInfo shortcutInfo = getShortcutInfo(shortcutId, packageName, user);
return shortcutInfo != null && shortcutInfo.isLongLived();
}
private void logBubbleError(String key, String failureMessage) {
if (DBG) {
Log.w(TAG, "Bubble notification: " + key + " failed: " + failureMessage);
}
}
/**
* Whether an intent is properly configured to display in an {@link android.app.ActivityView}.
*
* @param context the context to use.
* @param pendingIntent the pending intent of the bubble.
* @param packageName the notification package name for this bubble.
*/
// Keep checks in sync with BubbleController#canLaunchInActivityView.
@VisibleForTesting
protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
String packageName) {
if (pendingIntent == null) {
Log.w(TAG, "Unable to create bubble -- no intent");
return false;
}
// Need escalated privileges to get the intent.
final long token = Binder.clearCallingIdentity();
Intent intent;
try {
intent = pendingIntent.getIntent();
} finally {
Binder.restoreCallingIdentity(token);
}
ActivityInfo info = intent != null
? intent.resolveActivityInfo(context.getPackageManager(), 0)
: null;
if (info == null) {
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED, packageName,
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING);
Log.w(TAG, "Unable to send as bubble -- couldn't find activity info for intent: "
+ intent);
return false;
}
if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED, packageName,
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE);
Log.w(TAG, "Unable to send as bubble -- activity is not resizable for intent: "
+ intent);
return false;
}
return true;
}
};
private void doChannelWarningToast(CharSequence toastText) {
Binder.withCleanCallingIdentity(() -> {
@@ -6431,6 +6143,8 @@ public class NotificationManagerService extends SystemService {
cancelGroupChildrenLocked(r, mCallingUid, mCallingPid, listenerName,
mSendDelete, childrenFlagChecker);
updateLightsLocked();
mShortcutHelper.maybeListenForShortcutChangesForBubbles(r, true /* isRemoved */,
mHandler);
} else {
// No notification was found, assume that it is snoozed and cancel it.
if (mReason != REASON_SNOOZED) {
@@ -6498,7 +6212,7 @@ public class NotificationManagerService extends SystemService {
final String tag = n.getTag();
// We need to fix the notification up a little for bubbles
updateNotificationBubbleFlags(r, pkg, callingUid, old, isAppForeground);
updateNotificationBubbleFlags(r, isAppForeground);
// Handle grouped notifications and bail out early if we
// can to avoid extracting signals.
@@ -6668,6 +6382,10 @@ public class NotificationManagerService extends SystemService {
+ n.getPackageName());
}
mShortcutHelper.maybeListenForShortcutChangesForBubbles(r,
false /* isRemoved */,
mHandler);
maybeRecordInterruptionLocked(r);
// Log event to statsd
@@ -7427,6 +7145,7 @@ public class NotificationManagerService extends SystemService {
int[] visibilities = new int[N];
boolean[] showBadges = new boolean[N];
boolean[] allowBubbles = new boolean[N];
boolean[] isBubble = new boolean[N];
ArrayList<NotificationChannel> channelBefore = new ArrayList<>(N);
ArrayList<String> groupKeyBefore = new ArrayList<>(N);
ArrayList<ArrayList<String>> overridePeopleBefore = new ArrayList<>(N);
@@ -7442,6 +7161,7 @@ public class NotificationManagerService extends SystemService {
visibilities[i] = r.getPackageVisibilityOverride();
showBadges[i] = r.canShowBadge();
allowBubbles[i] = r.canBubble();
isBubble[i] = r.getNotification().isBubbleNotification();
channelBefore.add(r.getChannel());
groupKeyBefore.add(r.getGroupKey());
overridePeopleBefore.add(r.getPeopleOverride());
@@ -7460,6 +7180,7 @@ public class NotificationManagerService extends SystemService {
|| visibilities[i] != r.getPackageVisibilityOverride()
|| showBadges[i] != r.canShowBadge()
|| allowBubbles[i] != r.canBubble()
|| isBubble[i] != r.getNotification().isBubbleNotification()
|| !Objects.equals(channelBefore.get(i), r.getChannel())
|| !Objects.equals(groupKeyBefore.get(i), r.getGroupKey())
|| !Objects.equals(overridePeopleBefore.get(i), r.getPeopleOverride())
@@ -8622,7 +8343,8 @@ public class NotificationManagerService extends SystemService {
record.canBubble(),
record.isInterruptive(),
record.isConversation(),
record.getShortcutInfo()
record.getShortcutInfo(),
record.getNotification().isBubbleNotification()
);
rankings.add(ranking);
}

View File

@@ -0,0 +1,196 @@
/*
* Copyright (C) 2020 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.server.notification;
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED;
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
import android.annotation.NonNull;
import android.content.pm.LauncherApps;
import android.content.pm.ShortcutInfo;
import android.os.Binder;
import android.os.Handler;
import android.os.UserHandle;
import com.android.internal.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
/**
* Helper for querying shortcuts.
*/
class ShortcutHelper {
/**
* Listener to call when a shortcut we're tracking has been removed.
*/
interface ShortcutListener {
void onShortcutRemoved(String key);
}
private LauncherApps mLauncherAppsService;
private ShortcutListener mShortcutListener;
// Key: packageName Value: <shortcutId, notifId>
private HashMap<String, HashMap<String, String>> mActiveShortcutBubbles = new HashMap<>();
private boolean mLauncherAppsCallbackRegistered;
// Bubbles can be created based on a shortcut, we need to listen for changes to
// that shortcut so that we may update the bubble appropriately.
private final LauncherApps.Callback mLauncherAppsCallback = new LauncherApps.Callback() {
@Override
public void onPackageRemoved(String packageName, UserHandle user) {
}
@Override
public void onPackageAdded(String packageName, UserHandle user) {
}
@Override
public void onPackageChanged(String packageName, UserHandle user) {
}
@Override
public void onPackagesAvailable(String[] packageNames, UserHandle user,
boolean replacing) {
}
@Override
public void onPackagesUnavailable(String[] packageNames, UserHandle user,
boolean replacing) {
}
@Override
public void onShortcutsChanged(@NonNull String packageName,
@NonNull List<ShortcutInfo> shortcuts, @NonNull UserHandle user) {
HashMap<String, String> shortcutBubbles = mActiveShortcutBubbles.get(packageName);
ArrayList<String> bubbleKeysToRemove = new ArrayList<>();
if (shortcutBubbles != null) {
// If we can't find one of our bubbles in the shortcut list, that bubble needs
// to be removed.
for (String shortcutId : shortcutBubbles.keySet()) {
boolean foundShortcut = false;
for (int i = 0; i < shortcuts.size(); i++) {
if (shortcuts.get(i).getId().equals(shortcutId)) {
foundShortcut = true;
break;
}
}
if (!foundShortcut) {
bubbleKeysToRemove.add(shortcutBubbles.get(shortcutId));
}
}
}
// Let NoMan know about the updates
for (int i = 0; i < bubbleKeysToRemove.size(); i++) {
// update flag bubble
String bubbleKey = bubbleKeysToRemove.get(i);
if (mShortcutListener != null) {
mShortcutListener.onShortcutRemoved(bubbleKey);
}
}
}
};
ShortcutHelper(LauncherApps launcherApps, ShortcutListener listener) {
mLauncherAppsService = launcherApps;
mShortcutListener = listener;
}
@VisibleForTesting
void setLauncherApps(LauncherApps launcherApps) {
mLauncherAppsService = launcherApps;
}
ShortcutInfo getShortcutInfo(String shortcutId, String packageName, UserHandle user) {
if (mLauncherAppsService == null) {
return null;
}
final long token = Binder.clearCallingIdentity();
try {
if (shortcutId == null || packageName == null || user == null) {
return null;
}
LauncherApps.ShortcutQuery query = new LauncherApps.ShortcutQuery();
query.setPackage(packageName);
query.setShortcutIds(Arrays.asList(shortcutId));
query.setQueryFlags(FLAG_MATCH_DYNAMIC | FLAG_MATCH_PINNED | FLAG_MATCH_CACHED);
List<ShortcutInfo> shortcuts = mLauncherAppsService.getShortcuts(query, user);
return shortcuts != null && shortcuts.size() > 0
? shortcuts.get(0)
: null;
} finally {
Binder.restoreCallingIdentity(token);
}
}
boolean hasValidShortcutInfo(String shortcutId, String packageName,
UserHandle user) {
ShortcutInfo shortcutInfo = getShortcutInfo(shortcutId, packageName, user);
return shortcutInfo != null && shortcutInfo.isLongLived();
}
/**
* Shortcut based bubbles require some extra work to listen for shortcut changes.
*
* @param r the notification record to check
* @param removedNotification true if this notification is being removed
* @param handler handler to register the callback with
*/
void maybeListenForShortcutChangesForBubbles(NotificationRecord r, boolean removedNotification,
Handler handler) {
final String shortcutId = r.getNotification().getBubbleMetadata() != null
? r.getNotification().getBubbleMetadata().getShortcutId()
: null;
if (shortcutId == null) {
return;
}
if (r.getNotification().isBubbleNotification() && !removedNotification) {
// Must track shortcut based bubbles in case the shortcut is removed
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
r.getSbn().getPackageName());
if (packageBubbles == null) {
packageBubbles = new HashMap<>();
}
packageBubbles.put(shortcutId, r.getKey());
mActiveShortcutBubbles.put(r.getSbn().getPackageName(), packageBubbles);
if (!mLauncherAppsCallbackRegistered) {
mLauncherAppsService.registerCallback(mLauncherAppsCallback, handler);
mLauncherAppsCallbackRegistered = true;
}
} else {
// No longer track shortcut
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
r.getSbn().getPackageName());
if (packageBubbles != null) {
packageBubbles.remove(shortcutId);
}
if (packageBubbles != null && packageBubbles.isEmpty()) {
mActiveShortcutBubbles.remove(r.getSbn().getPackageName());
}
if (mLauncherAppsCallbackRegistered && mActiveShortcutBubbles.isEmpty()) {
mLauncherAppsService.unregisterCallback(mLauncherAppsCallback);
mLauncherAppsCallbackRegistered = false;
}
}
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright (C) 2020 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.server.notification;
import static android.content.pm.ActivityInfo.RESIZE_MODE_RESIZEABLE;
import static android.content.pm.ActivityInfo.RESIZE_MODE_UNRESIZEABLE;
import static junit.framework.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
import android.app.ActivityManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.os.UserHandle;
import android.service.notification.StatusBarNotification;
import android.test.suitebuilder.annotation.SmallTest;
import androidx.test.runner.AndroidJUnit4;
import com.android.server.UiServiceTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@SmallTest
@RunWith(AndroidJUnit4.class)
public class BubbleCheckerTest extends UiServiceTestCase {
private static final String SHORTCUT_ID = "shortcut";
private static final String PKG = "pkg";
private static final String KEY = "key";
private static final int USER_ID = 1;
@Mock
ActivityManager mActivityManager;
@Mock
RankingConfig mRankingConfig;
@Mock
ShortcutHelper mShortcutHelper;
@Mock
NotificationRecord mNr;
@Mock
UserHandle mUserHandle;
@Mock
Notification mNotif;
@Mock
StatusBarNotification mSbn;
@Mock
NotificationChannel mChannel;
@Mock
Notification.BubbleMetadata mBubbleMetadata;
@Mock
PendingIntent mPendingIntent;
@Mock
Intent mIntent;
BubbleExtractor.BubbleChecker mBubbleChecker;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
when(mNr.getKey()).thenReturn(KEY);
when(mNr.getSbn()).thenReturn(mSbn);
when(mNr.getUser()).thenReturn(mUserHandle);
when(mUserHandle.getIdentifier()).thenReturn(USER_ID);
when(mNr.getChannel()).thenReturn(mChannel);
when(mSbn.getPackageName()).thenReturn(PKG);
when(mSbn.getUser()).thenReturn(mUserHandle);
when(mNr.getNotification()).thenReturn(mNotif);
when(mNotif.getBubbleMetadata()).thenReturn(mBubbleMetadata);
mBubbleChecker = new BubbleExtractor.BubbleChecker(mContext,
mShortcutHelper,
mRankingConfig,
mActivityManager);
}
void setUpIntentBubble() {
when(mPendingIntent.getIntent()).thenReturn(mIntent);
when(mBubbleMetadata.getBubbleIntent()).thenReturn(mPendingIntent);
when(mBubbleMetadata.getShortcutId()).thenReturn(null);
}
void setUpShortcutBubble(boolean isValid) {
when(mBubbleMetadata.getShortcutId()).thenReturn(SHORTCUT_ID);
when(mShortcutHelper.hasValidShortcutInfo(SHORTCUT_ID, PKG, mUserHandle))
.thenReturn(isValid);
when(mBubbleMetadata.getBubbleIntent()).thenReturn(null);
}
void setUpBubblesEnabled(boolean feature, boolean app, boolean channel) {
when(mRankingConfig.bubblesEnabled()).thenReturn(feature);
when(mRankingConfig.areBubblesAllowed(PKG, USER_ID)).thenReturn(app);
when(mChannel.canBubble()).thenReturn(channel);
}
void setUpActivityIntent(boolean isResizable) {
when(mPendingIntent.getIntent()).thenReturn(mIntent);
ActivityInfo info = new ActivityInfo();
info.resizeMode = isResizable
? RESIZE_MODE_RESIZEABLE
: RESIZE_MODE_UNRESIZEABLE;
when(mIntent.resolveActivityInfo(any(), anyInt())).thenReturn(info);
}
//
// canBubble
//
@Test
public void testCanBubble_true_intentBubble() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
setUpIntentBubble();
setUpActivityIntent(true /* isResizable */);
when(mActivityManager.isLowRamDevice()).thenReturn(false);
assertTrue(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubble_true_shortcutBubble() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
setUpShortcutBubble(true /* isValid */);
assertTrue(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubble_false_noIntentInvalidShortcut() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
setUpShortcutBubble(false /* isValid */);
assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubble_false_noIntentNoShortcut() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
when(mBubbleMetadata.getBubbleIntent()).thenReturn(null);
when(mBubbleMetadata.getShortcutId()).thenReturn(null);
assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubbble_false_noMetadata() {
setUpBubblesEnabled(true/* feature */, true /* app */, true /* channel */);
when(mNotif.getBubbleMetadata()).thenReturn(null);
assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubble_false_bubblesNotEnabled() {
setUpBubblesEnabled(false /* feature */, true /* app */, true /* channel */);
assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubble_false_packageNotAllowed() {
setUpBubblesEnabled(true /* feature */, false /* app */, true /* channel */);
assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
@Test
public void testCanBubble_false_channelNotAllowed() {
setUpBubblesEnabled(true /* feature */, true /* app */, false /* channel */);
assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
}
//
// canLaunchInActivityView
//
@Test
public void testCanLaunchInActivityView_true() {
setUpActivityIntent(true /* resizable */);
assertTrue(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
}
@Test
public void testCanLaunchInActivityView_false_noIntent() {
when(mPendingIntent.getIntent()).thenReturn(null);
assertFalse(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
}
@Test
public void testCanLaunchInActivityView_false_noInfo() {
when(mPendingIntent.getIntent()).thenReturn(mIntent);
when(mIntent.resolveActivityInfo(any(), anyInt())).thenReturn(null);
assertFalse(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
}
@Test
public void testCanLaunchInActivityView_false_notResizable() {
setUpActivityIntent(false /* resizable */);
assertFalse(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
}
//
// isNotificationAppropriateToBubble
//
@Test
public void testIsNotifAppropriateToBubble_true() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
setUpIntentBubble();
when(mActivityManager.isLowRamDevice()).thenReturn(false);
setUpActivityIntent(true /* resizable */);
doReturn(Notification.MessagingStyle.class).when(mNotif).getNotificationStyle();
assertTrue(mBubbleChecker.isNotificationAppropriateToBubble(mNr));
}
@Test
public void testIsNotifAppropriateToBubble_false_lowRam() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
when(mActivityManager.isLowRamDevice()).thenReturn(true);
setUpActivityIntent(true /* resizable */);
doReturn(Notification.MessagingStyle.class).when(mNotif).getNotificationStyle();
assertFalse(mBubbleChecker.isNotificationAppropriateToBubble(mNr));
}
@Test
public void testIsNotifAppropriateToBubble_false_notMessageStyle() {
setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
when(mActivityManager.isLowRamDevice()).thenReturn(false);
setUpActivityIntent(true /* resizable */);
doReturn(Notification.BigPictureStyle.class).when(mNotif).getNotificationStyle();
assertFalse(mBubbleChecker.isNotificationAppropriateToBubble(mNr));
}
}

View File

@@ -21,6 +21,7 @@ import static android.app.NotificationManager.IMPORTANCE_UNSPECIFIED;
import static junit.framework.Assert.assertFalse;
import static junit.framework.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import android.app.ActivityManager;
@@ -46,6 +47,7 @@ import org.mockito.MockitoAnnotations;
public class BubbleExtractorTest extends UiServiceTestCase {
@Mock RankingConfig mConfig;
BubbleExtractor mBubbleExtractor;
private String mPkg = "com.android.server.notification";
private int mId = 1001;
@@ -57,6 +59,10 @@ public class BubbleExtractorTest extends UiServiceTestCase {
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mBubbleExtractor = new BubbleExtractor();
mBubbleExtractor.initialize(mContext, mock(NotificationUsageStats.class));
mBubbleExtractor.setConfig(mConfig);
mBubbleExtractor.setShortcutHelper(mock(ShortcutHelper.class));
}
private NotificationRecord getNotificationRecord(boolean allow, int importanceHigh) {
@@ -83,70 +89,55 @@ public class BubbleExtractorTest extends UiServiceTestCase {
@Test
public void testAppYesChannelNo() {
BubbleExtractor extractor = new BubbleExtractor();
extractor.setConfig(mConfig);
when(mConfig.bubblesEnabled()).thenReturn(true);
when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(true);
NotificationRecord r = getNotificationRecord(false, IMPORTANCE_UNSPECIFIED);
extractor.process(r);
mBubbleExtractor.process(r);
assertFalse(r.canBubble());
}
@Test
public void testAppNoChannelYes() throws Exception {
BubbleExtractor extractor = new BubbleExtractor();
extractor.setConfig(mConfig);
when(mConfig.bubblesEnabled()).thenReturn(true);
when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(false);
NotificationRecord r = getNotificationRecord(true, IMPORTANCE_HIGH);
extractor.process(r);
mBubbleExtractor.process(r);
assertFalse(r.canBubble());
}
@Test
public void testAppYesChannelYes() {
BubbleExtractor extractor = new BubbleExtractor();
extractor.setConfig(mConfig);
when(mConfig.bubblesEnabled()).thenReturn(true);
when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(true);
NotificationRecord r = getNotificationRecord(true, IMPORTANCE_UNSPECIFIED);
extractor.process(r);
mBubbleExtractor.process(r);
assertTrue(r.canBubble());
}
@Test
public void testAppNoChannelNo() {
BubbleExtractor extractor = new BubbleExtractor();
extractor.setConfig(mConfig);
when(mConfig.bubblesEnabled()).thenReturn(true);
when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(false);
NotificationRecord r = getNotificationRecord(false, IMPORTANCE_UNSPECIFIED);
extractor.process(r);
mBubbleExtractor.process(r);
assertFalse(r.canBubble());
}
@Test
public void testAppYesChannelYesUserNo() {
BubbleExtractor extractor = new BubbleExtractor();
extractor.setConfig(mConfig);
when(mConfig.bubblesEnabled()).thenReturn(false);
when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(true);
NotificationRecord r = getNotificationRecord(true, IMPORTANCE_HIGH);
extractor.process(r);
mBubbleExtractor.process(r);
assertFalse(r.canBubble());
}

View File

@@ -35,7 +35,6 @@ import android.app.INotificationManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.Person;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@@ -191,7 +190,8 @@ public class NotificationListenerServiceTest extends UiServiceTestCase {
tweak.canBubble(),
tweak.visuallyInterruptive(),
tweak.isConversation(),
tweak.getShortcutInfo()
tweak.getShortcutInfo(),
tweak.isBubble()
);
assertNotEquals(nru, nru2);
}
@@ -270,7 +270,8 @@ public class NotificationListenerServiceTest extends UiServiceTestCase {
canBubble(i),
visuallyInterruptive(i),
isConversation(i),
getShortcutInfo(i)
getShortcutInfo(i),
isBubble(i)
);
rankings[i] = ranking;
}
@@ -394,6 +395,10 @@ public class NotificationListenerServiceTest extends UiServiceTestCase {
return si;
}
private boolean isBubble(int index) {
return index % 4 == 0;
}
private void assertActionsEqual(
List<Notification.Action> expecteds, List<Notification.Action> actuals) {
assertEquals(expecteds.size(), actuals.size());

View File

@@ -202,6 +202,8 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
private TestableNotificationManagerService mService;
private INotificationManager mBinderService;
private NotificationManagerInternal mInternalService;
private TestableBubbleChecker mTestableBubbleChecker;
private ShortcutHelper mShortcutHelper;
@Mock
private IPackageManager mPackageManager;
@Mock
@@ -284,6 +286,10 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
super(context, logger, notificationInstanceIdSequence);
}
RankingHelper getRankingHelper() {
return mRankingHelper;
}
@Override
protected boolean isCallingUidSystem() {
countSystemChecks++;
@@ -335,6 +341,14 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
interface NotificationAssistantAccessGrantedCallback {
void onGranted(ComponentName assistant, int userId, boolean granted);
}
}
private class TestableBubbleChecker extends BubbleExtractor.BubbleChecker {
TestableBubbleChecker(Context context, ShortcutHelper helper, RankingConfig config,
ActivityManager manager) {
super(context, helper, config, manager);
}
@Override
protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
@@ -446,7 +460,16 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
mService.onBootPhase(SystemService.PHASE_SYSTEM_SERVICES_READY);
mService.setAudioManager(mAudioManager);
mService.setLauncherApps(mLauncherApps);
mShortcutHelper = mService.getShortcutHelper();
mShortcutHelper.setLauncherApps(mLauncherApps);
// Set the testable bubble extractor
RankingHelper rankingHelper = mService.getRankingHelper();
BubbleExtractor extractor = rankingHelper.findExtractor(BubbleExtractor.class);
mTestableBubbleChecker = new TestableBubbleChecker(mContext, mShortcutHelper,
mService.mPreferencesHelper, mActivityManager);
extractor.setBubbleChecker(mTestableBubbleChecker);
// Tests call directly into the Binder.
mBinderService = mService.getBinderService();
@@ -5776,6 +5799,9 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
@Test
public void testOnBubbleNotificationSuppressionChanged() throws Exception {
// Bubbles are allowed!
setUpPrefsForBubbles(PKG, mUid, true /* global */, true /* app */, true /* channel */);
// Bubble notification
NotificationRecord nr = generateMessageBubbleNotifRecord(mTestNotificationChannel, "tag");
@@ -6177,8 +6203,10 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
assertTrue(notif.isBubbleNotification());
// Test: Remove the shortcut
when(mLauncherApps.getShortcuts(any(), any())).thenReturn(null);
launcherAppsCallback.getValue().onShortcutsChanged(PKG, Collections.emptyList(),
new UserHandle(mUid));
waitForIdle();
// Verify:
@@ -6191,6 +6219,58 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
assertFalse(notif2.isBubbleNotification());
}
@Test
public void testNotificationBubbles_shortcut_stopListeningWhenNotifRemoved()
throws RemoteException {
// Bubbles are allowed!
setUpPrefsForBubbles(PKG, mUid, true /* global */, true /* app */, true /* channel */);
ArgumentCaptor<LauncherApps.Callback> launcherAppsCallback =
ArgumentCaptor.forClass(LauncherApps.Callback.class);
// Messaging notification with shortcut info
Notification.BubbleMetadata metadata =
getBubbleMetadataBuilder().createShortcutBubble("someshortcutId").build();
Notification.Builder nb = getMessageStyleNotifBuilder(false /* addDefaultMetadata */,
null /* groupKey */, false /* isSummary */);
nb.setBubbleMetadata(metadata);
StatusBarNotification sbn = new StatusBarNotification(PKG, PKG, 1,
"tag", mUid, 0, nb.build(), new UserHandle(mUid), null, 0);
NotificationRecord nr = new NotificationRecord(mContext, sbn, mTestNotificationChannel);
// Pretend the shortcut exists
List<ShortcutInfo> shortcutInfos = new ArrayList<>();
ShortcutInfo info = mock(ShortcutInfo.class);
when(info.isLongLived()).thenReturn(true);
shortcutInfos.add(info);
when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos);
// Test: Send the bubble notification
mBinderService.enqueueNotificationWithTag(PKG, PKG, nr.getSbn().getTag(),
nr.getSbn().getId(), nr.getSbn().getNotification(), nr.getSbn().getUserId());
waitForIdle();
// Verify:
// Make sure we register the callback for shortcut changes
verify(mLauncherApps, times(1)).registerCallback(launcherAppsCallback.capture(), any());
// yes allowed, yes messaging w/shortcut, yes bubble
Notification notif = mService.getNotificationRecord(nr.getSbn().getKey()).getNotification();
assertTrue(notif.isBubbleNotification());
// Test: Remove the notification
mBinderService.cancelNotificationWithTag(PKG, PKG, nr.getSbn().getTag(),
nr.getSbn().getId(), nr.getSbn().getUserId());
waitForIdle();
// Verify:
// Make sure callback is unregistered
verify(mLauncherApps, times(1)).unregisterCallback(launcherAppsCallback.getValue());
}
@Test
public void testNotificationBubbles_bubbleChildrenStay_whenGroupSummaryDismissed()
throws Exception {

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2020 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.server.notification;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.app.Notification;
import android.content.pm.LauncherApps;
import android.content.pm.ShortcutInfo;
import android.os.UserHandle;
import android.service.notification.StatusBarNotification;
import android.test.suitebuilder.annotation.SmallTest;
import android.testing.TestableLooper;
import androidx.test.runner.AndroidJUnit4;
import com.android.server.UiServiceTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.List;
@SmallTest
@RunWith(AndroidJUnit4.class)
@TestableLooper.RunWithLooper
public class ShortcutHelperTest extends UiServiceTestCase {
private static final String SHORTCUT_ID = "shortcut";
private static final String PKG = "pkg";
private static final String KEY = "key";
@Mock
LauncherApps mLauncherApps;
@Mock
ShortcutHelper.ShortcutListener mShortcutListener;
@Mock
NotificationRecord mNr;
@Mock
Notification mNotif;
@Mock
StatusBarNotification mSbn;
@Mock
Notification.BubbleMetadata mBubbleMetadata;
ShortcutHelper mShortcutHelper;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mShortcutHelper = new ShortcutHelper(mLauncherApps, mShortcutListener);
when(mNr.getKey()).thenReturn(KEY);
when(mNr.getSbn()).thenReturn(mSbn);
when(mSbn.getPackageName()).thenReturn(PKG);
when(mNr.getNotification()).thenReturn(mNotif);
when(mNotif.getBubbleMetadata()).thenReturn(mBubbleMetadata);
when(mBubbleMetadata.getShortcutId()).thenReturn(SHORTCUT_ID);
}
private LauncherApps.Callback addShortcutBubbleAndVerifyListener() {
when(mNotif.isBubbleNotification()).thenReturn(true);
mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
false /* removed */,
null /* handler */);
ArgumentCaptor<LauncherApps.Callback> launcherAppsCallback =
ArgumentCaptor.forClass(LauncherApps.Callback.class);
verify(mLauncherApps, times(1)).registerCallback(
launcherAppsCallback.capture(), any());
return launcherAppsCallback.getValue();
}
@Test
public void testBubbleAdded_listenedAdded() {
addShortcutBubbleAndVerifyListener();
}
@Test
public void testBubbleRemoved_listenerRemoved() {
// First set it up to listen
addShortcutBubbleAndVerifyListener();
// Then remove the notif
mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
true /* removed */,
null /* handler */);
verify(mLauncherApps, times(1)).unregisterCallback(any());
}
@Test
public void testBubbleNoLongerBubble_listenerRemoved() {
// First set it up to listen
addShortcutBubbleAndVerifyListener();
// Then make it not a bubble
when(mNotif.isBubbleNotification()).thenReturn(false);
mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
false /* removed */,
null /* handler */);
verify(mLauncherApps, times(1)).unregisterCallback(any());
}
@Test
public void testListenerNotifiedOnShortcutRemoved() {
LauncherApps.Callback callback = addShortcutBubbleAndVerifyListener();
List<ShortcutInfo> shortcutInfos = new ArrayList<>();
when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos);
callback.onShortcutsChanged(PKG, shortcutInfos, mock(UserHandle.class));
verify(mShortcutListener).onShortcutRemoved(mNr.getKey());
}
}