From 56515c43340d6aa5a26e2bb815f167cbc0be600e Mon Sep 17 00:00:00 2001 From: Mady Mellor Date: Tue, 18 Feb 2020 17:58:36 -0800 Subject: [PATCH] Include bubble changes in ranking & move flagging to BubbleExtractor Previously, only changes to the "allowBubbles" on the channel or package would trigger a ranking change. This bit only indicates that the notification is allowed to bubble -- it doesn't indicate that the notification *is* a bubble. To allow active notifications to become bubbles if the user changes the setting, we need to flag them in response to ranking changes. This CL moves the bubble flagging code into BubbleExtractor that way the flag is always updated during ranking changes. BubbleController listens to ranking changes and adds / removes bubbles based on the ranking. The ranking needs to have an isBubble bit on it because ranking changes won't pipe flag updates through. SysUI uses this bit to flag the entry on SysUI's side. Moves the shortcut getting / validating code into a helper class. Also removes the inline reply requirement. Test: NotificationManagerTest NotificationManagerServiceTest BubbleExtractorTest ShortcutHelperTest BubbleCheckerTest Bug: 149736441 Change-Id: Ib5b62923c123187ae5f7073ec7ca50d7e20c04b1 Merged-In: Ib5b62923c123187ae5f7073ec7ca50d7e20c04b1 --- .../NotificationListenerService.java | 22 +- .../systemui/bubbles/BubbleController.java | 30 +- .../android/systemui/bubbles/BubbleData.java | 26 -- .../statusbar/NotificationListener.java | 3 +- .../systemui/statusbar/RankingBuilder.java | 5 +- .../NotificationEntryManagerTest.java | 4 +- ...NotificationEntryManagerInflationTest.java | 3 +- .../server/notification/BubbleExtractor.java | 219 ++++++++- .../NotificationManagerService.java | 426 +++--------------- .../server/notification/ShortcutHelper.java | 196 ++++++++ .../notification/BubbleCheckerTest.java | 257 +++++++++++ .../notification/BubbleExtractorTest.java | 31 +- .../NotificationListenerServiceTest.java | 11 +- .../NotificationManagerServiceTest.java | 84 +++- .../notification/ShortcutHelperTest.java | 141 ++++++ 15 files changed, 1039 insertions(+), 419 deletions(-) create mode 100644 services/core/java/com/android/server/notification/ShortcutHelper.java create mode 100644 services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java create mode 100644 services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java diff --git a/core/java/android/service/notification/NotificationListenerService.java b/core/java/android/service/notification/NotificationListenerService.java index 0cd96b8ea688a..c52b02bb6a3ca 100644 --- a/core/java/android/service/notification/NotificationListenerService.java +++ b/core/java/android/service/notification/NotificationListenerService.java @@ -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 smartActions, ArrayList 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); } } diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java index 22c2c7ee97502..1138b026efb6a 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java @@ -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 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 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") diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java index cf5a4d3840cca..1fb5908913727 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java @@ -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; @@ -288,31 +287,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. * diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java index 047edd26c6891..72d9d0ee8f8f8 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java @@ -206,7 +206,8 @@ public class NotificationListener extends NotificationListenerWithPlugins { false, false, false, - null + null, + false ); } return ranking; diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java index fe8b89f381d65..a58000d095944 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java @@ -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; } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java index 312bb7f08e720..972357e960ef7 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java @@ -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)); } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java index 5d0349dbbb601..de5a38c622f5a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java @@ -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(); diff --git a/services/core/java/com/android/server/notification/BubbleExtractor.java b/services/core/java/com/android/server/notification/BubbleExtractor.java index c9c8042685d18..c96880cfebb80 100644 --- a/services/core/java/com/android/server/notification/BubbleExtractor.java +++ b/services/core/java/com/android/server/notification/BubbleExtractor.java @@ -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 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); + } + } + } } diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java index 69a5b35a5b12c..d0b2dfcbaca03 100755 --- a/services/core/java/com/android/server/notification/NotificationManagerService.java +++ b/services/core/java/com/android/server/notification/NotificationManagerService.java @@ -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; @@ -1186,13 +1175,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)); + } } } } @@ -1219,6 +1225,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 */)); } @@ -1595,80 +1602,6 @@ public class NotificationManagerService extends SystemService { } }; - // Key: packageName Value: - private HashMap> 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 shortcuts, @NonNull UserHandle user) { - HashMap shortcutBubbles = mActiveShortcutBubbles.get(packageName); - boolean isAppForeground = packageName != null - && mActivityManager.getPackageImportance(packageName) == IMPORTANCE_FOREGROUND; - ArrayList 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); @@ -1763,8 +1696,8 @@ public class NotificationManagerService extends SystemService { } @VisibleForTesting - void setLauncherApps(LauncherApps launcherApps) { - mLauncherAppsService = launcherApps; + ShortcutHelper getShortcutHelper() { + return mShortcutHelper; } @VisibleForTesting @@ -2314,8 +2247,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 @@ -3458,7 +3396,7 @@ public class NotificationManagerService extends SystemService { ArrayList 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())))); @@ -3481,7 +3419,7 @@ public class NotificationManagerService extends SystemService { ArrayList 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)))); @@ -5652,7 +5590,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)) { @@ -5780,16 +5718,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(); @@ -5799,252 +5733,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 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 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 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 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(() -> { @@ -6406,6 +6118,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) { @@ -6473,7 +6187,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. @@ -6643,6 +6357,10 @@ public class NotificationManagerService extends SystemService { + n.getPackageName()); } + mShortcutHelper.maybeListenForShortcutChangesForBubbles(r, + false /* isRemoved */, + mHandler); + maybeRecordInterruptionLocked(r); // Log event to statsd @@ -7402,6 +7120,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 channelBefore = new ArrayList<>(N); ArrayList groupKeyBefore = new ArrayList<>(N); ArrayList> overridePeopleBefore = new ArrayList<>(N); @@ -7417,6 +7136,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()); @@ -7435,6 +7155,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()) @@ -8597,7 +8318,8 @@ public class NotificationManagerService extends SystemService { record.canBubble(), record.isInterruptive(), record.isConversation(), - record.getShortcutInfo() + record.getShortcutInfo(), + record.getNotification().isBubbleNotification() ); rankings.add(ranking); } diff --git a/services/core/java/com/android/server/notification/ShortcutHelper.java b/services/core/java/com/android/server/notification/ShortcutHelper.java new file mode 100644 index 0000000000000..7bbb3b1175172 --- /dev/null +++ b/services/core/java/com/android/server/notification/ShortcutHelper.java @@ -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: + private HashMap> 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 shortcuts, @NonNull UserHandle user) { + HashMap shortcutBubbles = mActiveShortcutBubbles.get(packageName); + ArrayList 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 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 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 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; + } + } + } +} diff --git a/services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java b/services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java new file mode 100644 index 0000000000000..9636342c3f410 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java @@ -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)); + } + +} diff --git a/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java b/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java index 7459c4b0610ee..c7cef05ce4421 100644 --- a/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java @@ -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()); } diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java index dc8d0104353a6..8e7804786b388 100644 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java @@ -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 expecteds, List actuals) { assertEquals(expecteds.size(), actuals.size()); diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java index 64d481a2e6a0b..ed1af325341f2 100755 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java @@ -41,8 +41,6 @@ import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_PEEK; import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_SCREEN_OFF; import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_SCREEN_ON; import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_STATUS_BAR; -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_WATCH; import static android.content.pm.PackageManager.PERMISSION_DENIED; import static android.content.pm.PackageManager.PERMISSION_GRANTED; @@ -204,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 @@ -286,6 +286,10 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { super(context, logger, notificationInstanceIdSequence); } + RankingHelper getRankingHelper() { + return mRankingHelper; + } + @Override protected boolean isCallingUidSystem() { countSystemChecks++; @@ -337,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, @@ -448,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(); @@ -5710,6 +5731,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"); @@ -6111,8 +6135,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: @@ -6125,6 +6151,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 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 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 { diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java new file mode 100644 index 0000000000000..50fb9b4256522 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java @@ -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 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 shortcutInfos = new ArrayList<>(); + when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos); + + callback.onShortcutsChanged(PKG, shortcutInfos, mock(UserHandle.class)); + verify(mShortcutListener).onShortcutRemoved(mNr.getKey()); + } +}