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 406e7cef9bfa1..b39dd1aea4a97 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 54b83c0b134de..ff5e13c25859e 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; @@ -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. * 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 a21a047d9a70a..b568abbc8fde7 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 7b1003f391757..20ad87a096a0a 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; @@ -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: - 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); @@ -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 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 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 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(() -> { @@ -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 channelBefore = new ArrayList<>(N); ArrayList groupKeyBefore = new ArrayList<>(N); ArrayList> 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); } 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 6c8193074ef39..f179840fba113 100755 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java @@ -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 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()); + } +}