Merge "Include bubble changes in ranking & move flagging to BubbleExtractor" into rvc-dev am: 583092c980
Change-Id: Idad9adbfdd9374cbf908d86d385a3c3d0ba25037
This commit is contained in:
@@ -55,7 +55,6 @@ import android.os.ServiceManager;
|
||||
import android.os.UserHandle;
|
||||
import android.util.ArrayMap;
|
||||
import android.util.Log;
|
||||
import android.util.Slog;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.android.internal.annotations.GuardedBy;
|
||||
@@ -1570,6 +1569,7 @@ public abstract class NotificationListenerService extends Service {
|
||||
private boolean mVisuallyInterruptive;
|
||||
private boolean mIsConversation;
|
||||
private ShortcutInfo mShortcutInfo;
|
||||
private boolean mIsBubble;
|
||||
|
||||
private static final int PARCEL_VERSION = 2;
|
||||
|
||||
@@ -1604,6 +1604,7 @@ public abstract class NotificationListenerService extends Service {
|
||||
out.writeBoolean(mVisuallyInterruptive);
|
||||
out.writeBoolean(mIsConversation);
|
||||
out.writeParcelable(mShortcutInfo, flags);
|
||||
out.writeBoolean(mIsBubble);
|
||||
}
|
||||
|
||||
/** @hide */
|
||||
@@ -1639,6 +1640,7 @@ public abstract class NotificationListenerService extends Service {
|
||||
mVisuallyInterruptive = in.readBoolean();
|
||||
mIsConversation = in.readBoolean();
|
||||
mShortcutInfo = in.readParcelable(cl);
|
||||
mIsBubble = in.readBoolean();
|
||||
}
|
||||
|
||||
|
||||
@@ -1843,6 +1845,14 @@ public abstract class NotificationListenerService extends Service {
|
||||
return mIsConversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this notification is actively a bubble.
|
||||
* @hide
|
||||
*/
|
||||
public boolean isBubble() {
|
||||
return mIsBubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* @hide
|
||||
*/
|
||||
@@ -1862,7 +1872,8 @@ public abstract class NotificationListenerService extends Service {
|
||||
int userSentiment, boolean hidden, long lastAudiblyAlertedMs,
|
||||
boolean noisy, ArrayList<Notification.Action> smartActions,
|
||||
ArrayList<CharSequence> smartReplies, boolean canBubble,
|
||||
boolean visuallyInterruptive, boolean isConversation, ShortcutInfo shortcutInfo) {
|
||||
boolean visuallyInterruptive, boolean isConversation, ShortcutInfo shortcutInfo,
|
||||
boolean isBubble) {
|
||||
mKey = key;
|
||||
mRank = rank;
|
||||
mIsAmbient = importance < NotificationManager.IMPORTANCE_LOW;
|
||||
@@ -1886,6 +1897,7 @@ public abstract class NotificationListenerService extends Service {
|
||||
mVisuallyInterruptive = visuallyInterruptive;
|
||||
mIsConversation = isConversation;
|
||||
mShortcutInfo = shortcutInfo;
|
||||
mIsBubble = isBubble;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1913,7 +1925,8 @@ public abstract class NotificationListenerService extends Service {
|
||||
other.mCanBubble,
|
||||
other.mVisuallyInterruptive,
|
||||
other.mIsConversation,
|
||||
other.mShortcutInfo);
|
||||
other.mShortcutInfo,
|
||||
other.mIsBubble);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1970,7 +1983,8 @@ public abstract class NotificationListenerService extends Service {
|
||||
&& Objects.equals(mIsConversation, other.mIsConversation)
|
||||
// Shortcutinfo doesn't have equals either; use id
|
||||
&& Objects.equals((mShortcutInfo == null ? 0 : mShortcutInfo.getId()),
|
||||
(other.mShortcutInfo == null ? 0 : other.mShortcutInfo.getId()));
|
||||
(other.mShortcutInfo == null ? 0 : other.mShortcutInfo.getId()))
|
||||
&& Objects.equals(mIsBubble, other.mIsBubble);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import android.content.res.Configuration;
|
||||
import android.graphics.Rect;
|
||||
import android.os.RemoteException;
|
||||
import android.os.ServiceManager;
|
||||
import android.service.notification.NotificationListenerService;
|
||||
import android.service.notification.NotificationListenerService.RankingMap;
|
||||
import android.service.notification.ZenModeConfig;
|
||||
import android.util.ArraySet;
|
||||
@@ -146,13 +147,15 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
|
||||
private BubbleData mBubbleData;
|
||||
@Nullable private BubbleStackView mStackView;
|
||||
private BubbleIconFactory mBubbleIconFactory;
|
||||
private int mMaxBubbles;
|
||||
|
||||
// Tracks the id of the current (foreground) user.
|
||||
private int mCurrentUserId;
|
||||
// Saves notification keys of active bubbles when users are switched.
|
||||
private final SparseSetArray<String> mSavedBubbleKeysPerUser;
|
||||
|
||||
// Used when ranking updates occur and we check if things should bubble / unbubble
|
||||
private NotificationListenerService.Ranking mTmpRanking;
|
||||
|
||||
// Saves notification keys of user created "fake" bubbles so that we can allow notifications
|
||||
// like these to bubble by default. Doesn't persist across reboots, not a long-term solution.
|
||||
private final HashSet<String> mUserCreatedBubbles;
|
||||
@@ -338,7 +341,6 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
|
||||
|
||||
configurationController.addCallback(this /* configurationListener */);
|
||||
|
||||
mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
|
||||
mBubbleData = data;
|
||||
mBubbleData.setListener(mBubbleDataListener);
|
||||
mBubbleData.setSuppressionChangedListener(new NotificationSuppressionChangedListener() {
|
||||
@@ -939,9 +941,29 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when NotificationListener has received adjusted notification rank and reapplied
|
||||
* filtering and sorting. This is used to dismiss or create bubbles based on changes in
|
||||
* permissions on the notification channel or the global setting.
|
||||
*
|
||||
* @param rankingMap the updated ranking map from NotificationListenerService
|
||||
*/
|
||||
private void onRankingUpdated(RankingMap rankingMap) {
|
||||
// Forward to BubbleData to block any bubbles which should no longer be shown
|
||||
mBubbleData.notificationRankingUpdated(rankingMap);
|
||||
if (mTmpRanking == null) {
|
||||
mTmpRanking = new NotificationListenerService.Ranking();
|
||||
}
|
||||
String[] orderedKeys = rankingMap.getOrderedKeys();
|
||||
for (int i = 0; i < orderedKeys.length; i++) {
|
||||
String key = orderedKeys[i];
|
||||
NotificationEntry entry = mNotificationEntryManager.getPendingOrActiveNotif(key);
|
||||
rankingMap.getRanking(key, mTmpRanking);
|
||||
if (mBubbleData.hasBubbleWithKey(key) && !mTmpRanking.canBubble()) {
|
||||
mBubbleData.notificationEntryRemoved(entry, BubbleController.DISMISS_BLOCKED);
|
||||
} else if (entry != null && mTmpRanking.isBubble()) {
|
||||
entry.setFlagBubble(true);
|
||||
onEntryUpdated(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -206,7 +206,8 @@ public class NotificationListener extends NotificationListenerWithPlugins {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
null,
|
||||
false
|
||||
);
|
||||
}
|
||||
return ranking;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -15,9 +15,27 @@
|
||||
*/
|
||||
package com.android.server.notification;
|
||||
|
||||
import static android.app.Notification.CATEGORY_CALL;
|
||||
import static android.app.Notification.FLAG_BUBBLE;
|
||||
import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
|
||||
|
||||
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING;
|
||||
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Person;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.util.Slog;
|
||||
|
||||
import com.android.internal.annotations.VisibleForTesting;
|
||||
import com.android.internal.util.FrameworkStatsLog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Determines whether a bubble can be shown for this notification
|
||||
*/
|
||||
@@ -25,10 +43,15 @@ public class BubbleExtractor implements NotificationSignalExtractor {
|
||||
private static final String TAG = "BubbleExtractor";
|
||||
private static final boolean DBG = false;
|
||||
|
||||
private BubbleChecker mBubbleChecker;
|
||||
private RankingConfig mConfig;
|
||||
private ActivityManager mActivityManager;
|
||||
private Context mContext;
|
||||
|
||||
public void initialize(Context ctx, NotificationUsageStats usageStats) {
|
||||
public void initialize(Context context, NotificationUsageStats usageStats) {
|
||||
if (DBG) Slog.d(TAG, "Initializing " + getClass().getSimpleName() + ".");
|
||||
mContext = context;
|
||||
mActivityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
}
|
||||
|
||||
public RankingReconsideration process(NotificationRecord record) {
|
||||
@@ -41,6 +64,12 @@ public class BubbleExtractor implements NotificationSignalExtractor {
|
||||
if (DBG) Slog.d(TAG, "missing config");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mBubbleChecker == null) {
|
||||
if (DBG) Slog.d(TAG, "missing bubble checker");
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean appCanShowBubble =
|
||||
mConfig.areBubblesAllowed(record.getSbn().getPackageName(), record.getSbn().getUid());
|
||||
if (!mConfig.bubblesEnabled() || !appCanShowBubble) {
|
||||
@@ -52,7 +81,12 @@ public class BubbleExtractor implements NotificationSignalExtractor {
|
||||
record.setAllowBubble(appCanShowBubble);
|
||||
}
|
||||
}
|
||||
|
||||
final boolean applyFlag = mBubbleChecker.isNotificationAppropriateToBubble(record);
|
||||
if (applyFlag) {
|
||||
record.getNotification().flags |= FLAG_BUBBLE;
|
||||
} else {
|
||||
record.getNotification().flags &= ~FLAG_BUBBLE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -64,4 +98,185 @@ public class BubbleExtractor implements NotificationSignalExtractor {
|
||||
@Override
|
||||
public void setZenHelper(ZenModeHelper helper) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected to be called after {@link #setConfig(RankingConfig)} has occurred.
|
||||
*/
|
||||
void setShortcutHelper(ShortcutHelper helper) {
|
||||
if (mConfig == null) {
|
||||
if (DBG) Slog.d(TAG, "setting shortcut helper prior to setConfig");
|
||||
return;
|
||||
}
|
||||
mBubbleChecker = new BubbleChecker(mContext, helper, mConfig, mActivityManager);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void setBubbleChecker(BubbleChecker checker) {
|
||||
mBubbleChecker = checker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates special checks to see if a notification can be flagged as a bubble. This
|
||||
* makes testing a bit easier.
|
||||
*/
|
||||
public static class BubbleChecker {
|
||||
|
||||
private ActivityManager mActivityManager;
|
||||
private RankingConfig mRankingConfig;
|
||||
private Context mContext;
|
||||
private ShortcutHelper mShortcutHelper;
|
||||
|
||||
BubbleChecker(Context context, ShortcutHelper helper, RankingConfig config,
|
||||
ActivityManager activityManager) {
|
||||
mContext = context;
|
||||
mActivityManager = activityManager;
|
||||
mShortcutHelper = helper;
|
||||
mRankingConfig = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the provided notification record is allowed to be represented as a
|
||||
* bubble, accounting for user choice & policy.
|
||||
*/
|
||||
public boolean isNotificationAppropriateToBubble(NotificationRecord r) {
|
||||
final String pkg = r.getSbn().getPackageName();
|
||||
final int userId = r.getSbn().getUser().getIdentifier();
|
||||
Notification notification = r.getNotification();
|
||||
if (!canBubble(r, pkg, userId)) {
|
||||
// no log: canBubble has its own
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mActivityManager.isLowRamDevice()) {
|
||||
logBubbleError(r.getKey(), "low ram device");
|
||||
return false;
|
||||
}
|
||||
|
||||
// At this point the bubble must fulfill communication policy
|
||||
|
||||
// Communication always needs a person
|
||||
ArrayList<Person> peopleList = notification.extras != null
|
||||
? notification.extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST)
|
||||
: null;
|
||||
// Message style requires a person & it's not included in the list
|
||||
boolean isMessageStyle = Notification.MessagingStyle.class.equals(
|
||||
notification.getNotificationStyle());
|
||||
if (!isMessageStyle && (peopleList == null || peopleList.isEmpty())) {
|
||||
logBubbleError(r.getKey(), "Must have a person and be "
|
||||
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Communication is a message or a call
|
||||
boolean isCall = CATEGORY_CALL.equals(notification.category);
|
||||
boolean hasForegroundService = (notification.flags & FLAG_FOREGROUND_SERVICE) != 0;
|
||||
if (hasForegroundService && !isCall) {
|
||||
logBubbleError(r.getKey(),
|
||||
"foreground services must be Notification.CATEGORY_CALL to bubble");
|
||||
return false;
|
||||
}
|
||||
if (isMessageStyle) {
|
||||
return true;
|
||||
} else if (isCall) {
|
||||
if (hasForegroundService) {
|
||||
return true;
|
||||
}
|
||||
logBubbleError(r.getKey(), "calls require foreground service");
|
||||
return false;
|
||||
}
|
||||
logBubbleError(r.getKey(), "Must be "
|
||||
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the user has enabled the provided notification to bubble, does not
|
||||
* account for policy.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
boolean canBubble(NotificationRecord r, String pkg, int userId) {
|
||||
Notification notification = r.getNotification();
|
||||
Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
|
||||
if (metadata == null) {
|
||||
// no log: no need to inform dev if they didn't attach bubble metadata
|
||||
return false;
|
||||
}
|
||||
if (!mRankingConfig.bubblesEnabled()) {
|
||||
logBubbleError(r.getKey(), "bubbles disabled for user: " + userId);
|
||||
return false;
|
||||
}
|
||||
if (!mRankingConfig.areBubblesAllowed(pkg, userId)) {
|
||||
logBubbleError(r.getKey(),
|
||||
"bubbles for package: " + pkg + " disabled for user: " + userId);
|
||||
return false;
|
||||
}
|
||||
if (!r.getChannel().canBubble()) {
|
||||
logBubbleError(r.getKey(),
|
||||
"bubbles for channel " + r.getChannel().getId() + " disabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
String shortcutId = metadata.getShortcutId();
|
||||
boolean shortcutValid = shortcutId != null
|
||||
&& mShortcutHelper.hasValidShortcutInfo(shortcutId, pkg, r.getUser());
|
||||
if (metadata.getBubbleIntent() == null && !shortcutValid) {
|
||||
// Should have a shortcut if intent is null
|
||||
logBubbleError(r.getKey(),
|
||||
"couldn't find valid shortcut for bubble with shortcutId: " + shortcutId);
|
||||
return false;
|
||||
}
|
||||
if (shortcutValid) {
|
||||
return true;
|
||||
}
|
||||
// no log: canLaunch method has the failure log
|
||||
return canLaunchInActivityView(mContext, metadata.getBubbleIntent(), pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an intent is properly configured to display in an {@link
|
||||
* android.app.ActivityView}.
|
||||
*
|
||||
* @param context the context to use.
|
||||
* @param pendingIntent the pending intent of the bubble.
|
||||
* @param packageName the notification package name for this bubble.
|
||||
*/
|
||||
// Keep checks in sync with BubbleController#canLaunchInActivityView.
|
||||
@VisibleForTesting
|
||||
protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
|
||||
String packageName) {
|
||||
if (pendingIntent == null) {
|
||||
Slog.w(TAG, "Unable to create bubble -- no intent");
|
||||
return false;
|
||||
}
|
||||
|
||||
Intent intent = pendingIntent.getIntent();
|
||||
|
||||
ActivityInfo info = intent != null
|
||||
? intent.resolveActivityInfo(context.getPackageManager(), 0)
|
||||
: null;
|
||||
if (info == null) {
|
||||
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED,
|
||||
packageName,
|
||||
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING);
|
||||
Slog.w(TAG, "Unable to send as bubble -- couldn't find activity info for intent: "
|
||||
+ intent);
|
||||
return false;
|
||||
}
|
||||
if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
|
||||
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED,
|
||||
packageName,
|
||||
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE);
|
||||
Slog.w(TAG, "Unable to send as bubble -- activity is not resizable for intent: "
|
||||
+ intent);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void logBubbleError(String key, String failureMessage) {
|
||||
if (DBG) {
|
||||
Slog.w(TAG, "Bubble notification: " + key + " failed: " + failureMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package com.android.server.notification;
|
||||
|
||||
import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
|
||||
import static android.app.Notification.CATEGORY_CALL;
|
||||
import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY;
|
||||
import static android.app.Notification.FLAG_BUBBLE;
|
||||
import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
|
||||
@@ -51,9 +50,6 @@ import static android.content.Context.BIND_ALLOW_WHITELIST_MANAGEMENT;
|
||||
import static android.content.Context.BIND_AUTO_CREATE;
|
||||
import static android.content.Context.BIND_FOREGROUND_SERVICE;
|
||||
import static android.content.Context.BIND_NOT_PERCEPTIBLE;
|
||||
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED;
|
||||
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
|
||||
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
|
||||
import static android.content.pm.PackageManager.FEATURE_LEANBACK;
|
||||
import static android.content.pm.PackageManager.FEATURE_TELEVISION;
|
||||
import static android.content.pm.PackageManager.MATCH_ALL;
|
||||
@@ -93,8 +89,6 @@ import static android.service.notification.NotificationListenerService.TRIM_FULL
|
||||
import static android.service.notification.NotificationListenerService.TRIM_LIGHT;
|
||||
import static android.view.WindowManager.LayoutParams.TYPE_TOAST;
|
||||
|
||||
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING;
|
||||
import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE;
|
||||
import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_CHANNEL_GROUP_PREFERENCES;
|
||||
import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_CHANNEL_PREFERENCES;
|
||||
import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_PREFERENCES;
|
||||
@@ -131,8 +125,6 @@ import android.app.NotificationHistory.HistoricalNotification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.NotificationManager.Policy;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Person;
|
||||
import android.app.RemoteInput;
|
||||
import android.app.StatsManager;
|
||||
import android.app.StatusBarManager;
|
||||
import android.app.UriGrantsManager;
|
||||
@@ -152,7 +144,6 @@ import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.IPackageManager;
|
||||
import android.content.pm.LauncherApps;
|
||||
@@ -160,7 +151,6 @@ import android.content.pm.PackageManager;
|
||||
import android.content.pm.PackageManager.NameNotFoundException;
|
||||
import android.content.pm.PackageManagerInternal;
|
||||
import android.content.pm.ParceledListSlice;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.content.pm.UserInfo;
|
||||
import android.content.res.Resources;
|
||||
import android.database.ContentObserver;
|
||||
@@ -251,7 +241,6 @@ import com.android.internal.util.ArrayUtils;
|
||||
import com.android.internal.util.CollectionUtils;
|
||||
import com.android.internal.util.DumpUtils;
|
||||
import com.android.internal.util.FastXmlSerializer;
|
||||
import com.android.internal.util.FrameworkStatsLog;
|
||||
import com.android.internal.util.Preconditions;
|
||||
import com.android.internal.util.XmlUtils;
|
||||
import com.android.internal.util.function.TriPredicate;
|
||||
@@ -296,7 +285,6 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
@@ -421,7 +409,7 @@ public class NotificationManagerService extends SystemService {
|
||||
private RoleObserver mRoleObserver;
|
||||
private UserManager mUm;
|
||||
private IPlatformCompat mPlatformCompat;
|
||||
private LauncherApps mLauncherAppsService;
|
||||
private ShortcutHelper mShortcutHelper;
|
||||
|
||||
final IBinder mForegroundToken = new Binder();
|
||||
private WorkerHandler mHandler;
|
||||
@@ -497,7 +485,8 @@ public class NotificationManagerService extends SystemService {
|
||||
"allow-secure-notifications-on-lockscreen";
|
||||
private static final String LOCKSCREEN_ALLOW_SECURE_NOTIFICATIONS_VALUE = "value";
|
||||
|
||||
private RankingHelper mRankingHelper;
|
||||
@VisibleForTesting
|
||||
RankingHelper mRankingHelper;
|
||||
@VisibleForTesting
|
||||
PreferencesHelper mPreferencesHelper;
|
||||
|
||||
@@ -1203,13 +1192,30 @@ public class NotificationManagerService extends SystemService {
|
||||
|
||||
@Override
|
||||
public void onNotificationBubbleChanged(String key, boolean isBubble) {
|
||||
String pkg;
|
||||
synchronized (mNotificationLock) {
|
||||
NotificationRecord r = mNotificationsByKey.get(key);
|
||||
pkg = r != null && r.getSbn() != null ? r.getSbn().getPackageName() : null;
|
||||
}
|
||||
boolean isAppForeground = pkg != null
|
||||
&& mActivityManager.getPackageImportance(pkg) == IMPORTANCE_FOREGROUND;
|
||||
synchronized (mNotificationLock) {
|
||||
NotificationRecord r = mNotificationsByKey.get(key);
|
||||
if (r != null) {
|
||||
final StatusBarNotification n = r.getSbn();
|
||||
final int callingUid = n.getUid();
|
||||
final String pkg = n.getPackageName();
|
||||
applyFlagBubble(r, pkg, callingUid, null /* oldEntry */, isBubble);
|
||||
if (!isBubble) {
|
||||
// This happens if the user has dismissed the bubble but the notification
|
||||
// is still active in the shade, enqueuing would create a bubble since
|
||||
// the notification is technically allowed. Flip the flag so that
|
||||
// apps querying noMan will know that their notification is not showing
|
||||
// as a bubble.
|
||||
r.getNotification().flags &= ~FLAG_BUBBLE;
|
||||
} else {
|
||||
// Enqueue will trigger resort & if the flag is allowed to be true it'll
|
||||
// be applied there.
|
||||
r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
|
||||
mHandler.post(new EnqueueNotificationRunnable(r.getUser().getIdentifier(),
|
||||
r, isAppForeground));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1242,7 @@ public class NotificationManagerService extends SystemService {
|
||||
flags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
|
||||
}
|
||||
data.setFlags(flags);
|
||||
r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
|
||||
mHandler.post(new EnqueueNotificationRunnable(r.getUser().getIdentifier(), r,
|
||||
true /* isAppForeground */));
|
||||
}
|
||||
@@ -1615,80 +1622,6 @@ public class NotificationManagerService extends SystemService {
|
||||
}
|
||||
};
|
||||
|
||||
// Key: packageName Value: <shortcutId, notifId>
|
||||
private HashMap<String, HashMap<String, String>> mActiveShortcutBubbles = new HashMap<>();
|
||||
|
||||
private boolean mLauncherAppsCallbackRegistered;
|
||||
|
||||
// Bubbles can be created based on a shortcut, we need to listen for changes to
|
||||
// that shortcut so that we may update the bubble appropriately.
|
||||
private final LauncherApps.Callback mLauncherAppsCallback = new LauncherApps.Callback() {
|
||||
@Override
|
||||
public void onPackageRemoved(String packageName, UserHandle user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackageAdded(String packageName, UserHandle user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackageChanged(String packageName, UserHandle user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackagesAvailable(String[] packageNames, UserHandle user,
|
||||
boolean replacing) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackagesUnavailable(String[] packageNames, UserHandle user,
|
||||
boolean replacing) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShortcutsChanged(@NonNull String packageName,
|
||||
@NonNull List<ShortcutInfo> shortcuts, @NonNull UserHandle user) {
|
||||
HashMap<String, String> shortcutBubbles = mActiveShortcutBubbles.get(packageName);
|
||||
boolean isAppForeground = packageName != null
|
||||
&& mActivityManager.getPackageImportance(packageName) == IMPORTANCE_FOREGROUND;
|
||||
ArrayList<String> bubbleKeysToRemove = new ArrayList<>();
|
||||
if (shortcutBubbles != null) {
|
||||
// If we can't find one of our bubbles in the shortcut list, that bubble needs
|
||||
// to be removed.
|
||||
for (String shortcutId : shortcutBubbles.keySet()) {
|
||||
boolean foundShortcut = false;
|
||||
for (int i = 0; i < shortcuts.size(); i++) {
|
||||
if (shortcuts.get(i).getId().equals(shortcutId)) {
|
||||
foundShortcut = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundShortcut) {
|
||||
bubbleKeysToRemove.add(shortcutBubbles.get(shortcutId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do the removals
|
||||
for (int i = 0; i < bubbleKeysToRemove.size(); i++) {
|
||||
// update flag bubble
|
||||
String bubbleKey = bubbleKeysToRemove.get(i);
|
||||
synchronized (mNotificationLock) {
|
||||
NotificationRecord r = mNotificationsByKey.get(bubbleKey);
|
||||
if (r != null) {
|
||||
final StatusBarNotification n = r.getSbn();
|
||||
final int callingUid = n.getUid();
|
||||
final String pkg = n.getPackageName();
|
||||
applyFlagBubble(r, pkg, callingUid, null /* oldEntry */, isAppForeground);
|
||||
mHandler.post(new EnqueueNotificationRunnable(user.getIdentifier(), r,
|
||||
false /* isAppForeground */));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
private final class SettingsObserver extends ContentObserver {
|
||||
private final Uri NOTIFICATION_BADGING_URI
|
||||
= Settings.Secure.getUriFor(Settings.Secure.NOTIFICATION_BADGING);
|
||||
@@ -1783,8 +1716,8 @@ public class NotificationManagerService extends SystemService {
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void setLauncherApps(LauncherApps launcherApps) {
|
||||
mLauncherAppsService = launcherApps;
|
||||
ShortcutHelper getShortcutHelper() {
|
||||
return mShortcutHelper;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -2334,8 +2267,13 @@ public class NotificationManagerService extends SystemService {
|
||||
mRoleObserver = new RoleObserver(getContext().getSystemService(RoleManager.class),
|
||||
mPackageManager, getContext().getMainExecutor());
|
||||
mRoleObserver.init();
|
||||
mLauncherAppsService =
|
||||
LauncherApps launcherApps =
|
||||
(LauncherApps) getContext().getSystemService(Context.LAUNCHER_APPS_SERVICE);
|
||||
mShortcutHelper = new ShortcutHelper(launcherApps, mShortcutListener);
|
||||
BubbleExtractor bubbsExtractor = mRankingHelper.findExtractor(BubbleExtractor.class);
|
||||
if (bubbsExtractor != null) {
|
||||
bubbsExtractor.setShortcutHelper(mShortcutHelper);
|
||||
}
|
||||
registerNotificationPreferencesPullers();
|
||||
} else if (phase == SystemService.PHASE_THIRD_PARTY_APPS_CAN_START) {
|
||||
// This observer will force an update when observe is called, causing us to
|
||||
@@ -3478,7 +3416,7 @@ public class NotificationManagerService extends SystemService {
|
||||
ArrayList<ConversationChannelWrapper> conversations =
|
||||
mPreferencesHelper.getConversations(onlyImportant);
|
||||
for (ConversationChannelWrapper conversation : conversations) {
|
||||
conversation.setShortcutInfo(getShortcutInfo(
|
||||
conversation.setShortcutInfo(mShortcutHelper.getShortcutInfo(
|
||||
conversation.getNotificationChannel().getConversationId(),
|
||||
conversation.getPkg(),
|
||||
UserHandle.of(UserHandle.getUserId(conversation.getUid()))));
|
||||
@@ -3501,7 +3439,7 @@ public class NotificationManagerService extends SystemService {
|
||||
ArrayList<ConversationChannelWrapper> conversations =
|
||||
mPreferencesHelper.getConversations(pkg, uid);
|
||||
for (ConversationChannelWrapper conversation : conversations) {
|
||||
conversation.setShortcutInfo(getShortcutInfo(
|
||||
conversation.setShortcutInfo(mShortcutHelper.getShortcutInfo(
|
||||
conversation.getNotificationChannel().getConversationId(),
|
||||
pkg,
|
||||
UserHandle.of(UserHandle.getUserId(uid))));
|
||||
@@ -5672,7 +5610,7 @@ public class NotificationManagerService extends SystemService {
|
||||
}
|
||||
}
|
||||
|
||||
r.setShortcutInfo(getShortcutInfo(notification.getShortcutId(), pkg, user));
|
||||
r.setShortcutInfo(mShortcutHelper.getShortcutInfo(notification.getShortcutId(), pkg, user));
|
||||
|
||||
if (!checkDisqualifyingFeatures(userId, notificationUid, id, tag, r,
|
||||
r.getSbn().getOverrideGroupKey() != null)) {
|
||||
@@ -5800,16 +5738,12 @@ public class NotificationManagerService extends SystemService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the flags for this notification to reflect whether it is a bubble or not. Some
|
||||
* bubble specific flags only work if the app is foreground, this will strip those flags
|
||||
* Some bubble specific flags only work if the app is foreground, this will strip those flags
|
||||
* if the app wasn't foreground.
|
||||
*/
|
||||
private void updateNotificationBubbleFlags(NotificationRecord r, String pkg, int userId,
|
||||
NotificationRecord oldRecord, boolean isAppForeground) {
|
||||
Notification notification = r.getNotification();
|
||||
applyFlagBubble(r, pkg, userId, oldRecord, true /* desiredFlag */);
|
||||
|
||||
private void updateNotificationBubbleFlags(NotificationRecord r, boolean isAppForeground) {
|
||||
// Remove any bubble specific flags that only work when foregrounded
|
||||
Notification notification = r.getNotification();
|
||||
Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
|
||||
if (!isAppForeground && metadata != null) {
|
||||
int flags = metadata.getFlags();
|
||||
@@ -5819,252 +5753,30 @@ public class NotificationManagerService extends SystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles actually applying or removing {@link Notification#FLAG_BUBBLE}. Performs necessary
|
||||
* checks for the provided record to see if it can actually be a bubble.
|
||||
* Tracks shortcut based bubbles so that we can find out if they've changed or been removed.
|
||||
*/
|
||||
private void applyFlagBubble(NotificationRecord r, String pkg, int userId,
|
||||
NotificationRecord oldRecord, boolean desiredFlag) {
|
||||
boolean applyFlag = desiredFlag
|
||||
&& isNotificationAppropriateToBubble(r, pkg, userId, oldRecord);
|
||||
final String shortcutId = r.getNotification().getBubbleMetadata() != null
|
||||
? r.getNotification().getBubbleMetadata().getShortcutId()
|
||||
: null;
|
||||
if (applyFlag) {
|
||||
if (shortcutId != null) {
|
||||
// Must track shortcut based bubbles in case the shortcut is removed
|
||||
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
|
||||
r.getSbn().getPackageName());
|
||||
if (packageBubbles == null) {
|
||||
packageBubbles = new HashMap<>();
|
||||
private ShortcutHelper.ShortcutListener mShortcutListener =
|
||||
new ShortcutHelper.ShortcutListener() {
|
||||
@Override
|
||||
public void onShortcutRemoved(String key) {
|
||||
String packageName;
|
||||
synchronized (mNotificationLock) {
|
||||
NotificationRecord r = mNotificationsByKey.get(key);
|
||||
packageName = r != null ? r.getSbn().getPackageName() : null;
|
||||
}
|
||||
boolean isAppForeground = packageName != null
|
||||
&& mActivityManager.getPackageImportance(packageName)
|
||||
== IMPORTANCE_FOREGROUND;
|
||||
synchronized (mNotificationLock) {
|
||||
NotificationRecord r = mNotificationsByKey.get(key);
|
||||
if (r != null) {
|
||||
// Enqueue will trigger resort & flag is updated that way.
|
||||
r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
|
||||
mHandler.post(
|
||||
new NotificationManagerService.EnqueueNotificationRunnable(
|
||||
r.getUser().getIdentifier(), r, isAppForeground));
|
||||
}
|
||||
}
|
||||
}
|
||||
packageBubbles.put(shortcutId, r.getKey());
|
||||
mActiveShortcutBubbles.put(r.getSbn().getPackageName(), packageBubbles);
|
||||
if (!mLauncherAppsCallbackRegistered) {
|
||||
mLauncherAppsService.registerCallback(mLauncherAppsCallback, mHandler);
|
||||
mLauncherAppsCallbackRegistered = true;
|
||||
}
|
||||
}
|
||||
r.getNotification().flags |= FLAG_BUBBLE;
|
||||
} else {
|
||||
if (shortcutId != null) {
|
||||
// No longer track shortcut
|
||||
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
|
||||
r.getSbn().getPackageName());
|
||||
if (packageBubbles != null) {
|
||||
packageBubbles.remove(shortcutId);
|
||||
}
|
||||
if (packageBubbles != null && packageBubbles.isEmpty()) {
|
||||
mActiveShortcutBubbles.remove(r.getSbn().getPackageName());
|
||||
}
|
||||
if (mLauncherAppsCallbackRegistered && mActiveShortcutBubbles.isEmpty()) {
|
||||
mLauncherAppsService.unregisterCallback(mLauncherAppsCallback);
|
||||
mLauncherAppsCallbackRegistered = false;
|
||||
}
|
||||
}
|
||||
r.getNotification().flags &= ~FLAG_BUBBLE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the provided notification record is allowed to be represented as a bubble,
|
||||
* accounting for user choice & policy.
|
||||
*/
|
||||
private boolean isNotificationAppropriateToBubble(NotificationRecord r, String pkg, int userId,
|
||||
NotificationRecord oldRecord) {
|
||||
Notification notification = r.getNotification();
|
||||
if (!canBubble(r, pkg, userId)) {
|
||||
// no log: canBubble has its own
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mActivityManager.isLowRamDevice()) {
|
||||
logBubbleError(r.getKey(), "low ram device");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (oldRecord != null && (oldRecord.getNotification().flags & FLAG_BUBBLE) != 0) {
|
||||
// This is an update to an active bubble
|
||||
return true;
|
||||
}
|
||||
|
||||
// At this point the bubble must fulfill communication policy
|
||||
|
||||
// Communication always needs a person
|
||||
ArrayList<Person> peopleList = notification.extras != null
|
||||
? notification.extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST)
|
||||
: null;
|
||||
// Message style requires a person & it's not included in the list
|
||||
boolean isMessageStyle = Notification.MessagingStyle.class.equals(
|
||||
notification.getNotificationStyle());
|
||||
if (!isMessageStyle && (peopleList == null || peopleList.isEmpty())) {
|
||||
logBubbleError(r.getKey(), "Must have a person and be "
|
||||
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Communication is a message or a call
|
||||
boolean isCall = CATEGORY_CALL.equals(notification.category);
|
||||
boolean hasForegroundService = (notification.flags & FLAG_FOREGROUND_SERVICE) != 0;
|
||||
if (hasForegroundService && !isCall) {
|
||||
logBubbleError(r.getKey(),
|
||||
"foreground services must be Notification.CATEGORY_CALL to bubble");
|
||||
return false;
|
||||
}
|
||||
if (isMessageStyle) {
|
||||
if (hasValidRemoteInput(notification)) {
|
||||
return true;
|
||||
}
|
||||
logBubbleError(r.getKey(), "messages require valid remote input");
|
||||
return false;
|
||||
} else if (isCall) {
|
||||
if (hasForegroundService) {
|
||||
return true;
|
||||
}
|
||||
logBubbleError(r.getKey(), "calls require foreground service");
|
||||
return false;
|
||||
}
|
||||
logBubbleError(r.getKey(), "Must be "
|
||||
+ "Notification.MessageStyle or Notification.CATEGORY_CALL");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the user has enabled the provided notification to bubble, does not account
|
||||
* for policy.
|
||||
*/
|
||||
private boolean canBubble(NotificationRecord r, String pkg, int userId) {
|
||||
Notification notification = r.getNotification();
|
||||
Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
|
||||
if (metadata == null) {
|
||||
// no log: no need to inform dev if they didn't attach bubble metadata
|
||||
return false;
|
||||
}
|
||||
if (!mPreferencesHelper.bubblesEnabled()) {
|
||||
logBubbleError(r.getKey(), "bubbles disabled for user: " + userId);
|
||||
return false;
|
||||
}
|
||||
if (!mPreferencesHelper.areBubblesAllowed(pkg, userId)) {
|
||||
logBubbleError(r.getKey(),
|
||||
"bubbles for package: " + pkg + " disabled for user: " + userId);
|
||||
return false;
|
||||
}
|
||||
if (!r.getChannel().canBubble()) {
|
||||
logBubbleError(r.getKey(),
|
||||
"bubbles for channel " + r.getChannel().getId() + " disabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
String shortcutId = metadata.getShortcutId();
|
||||
boolean shortcutValid = shortcutId != null
|
||||
&& hasValidShortcutInfo(shortcutId, pkg, r.getUser());
|
||||
if (metadata.getBubbleIntent() == null && !shortcutValid) {
|
||||
// Should have a shortcut if intent is null
|
||||
logBubbleError(r.getKey(), "couldn't find shortcutId for bubble: " + shortcutId);
|
||||
return false;
|
||||
}
|
||||
if (shortcutValid) {
|
||||
return true;
|
||||
}
|
||||
// no log: canLaunch method has the failure log
|
||||
return canLaunchInActivityView(getContext(), metadata.getBubbleIntent(), pkg);
|
||||
}
|
||||
|
||||
private boolean hasValidRemoteInput(Notification n) {
|
||||
// Also check for inline reply
|
||||
Notification.Action[] actions = n.actions;
|
||||
if (actions != null) {
|
||||
// Get the remote inputs
|
||||
for (int i = 0; i < actions.length; i++) {
|
||||
Notification.Action action = actions[i];
|
||||
RemoteInput[] inputs = action.getRemoteInputs();
|
||||
if (inputs != null && inputs.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private ShortcutInfo getShortcutInfo(String shortcutId, String packageName, UserHandle user) {
|
||||
final long token = Binder.clearCallingIdentity();
|
||||
try {
|
||||
if (shortcutId == null || packageName == null || user == null) {
|
||||
return null;
|
||||
}
|
||||
LauncherApps.ShortcutQuery query = new LauncherApps.ShortcutQuery();
|
||||
if (packageName != null) {
|
||||
query.setPackage(packageName);
|
||||
}
|
||||
if (shortcutId != null) {
|
||||
query.setShortcutIds(Arrays.asList(shortcutId));
|
||||
}
|
||||
query.setQueryFlags(FLAG_MATCH_DYNAMIC | FLAG_MATCH_PINNED | FLAG_MATCH_CACHED);
|
||||
List<ShortcutInfo> shortcuts = mLauncherAppsService.getShortcuts(query, user);
|
||||
ShortcutInfo shortcutInfo = shortcuts != null && shortcuts.size() > 0
|
||||
? shortcuts.get(0)
|
||||
: null;
|
||||
return shortcutInfo;
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasValidShortcutInfo(String shortcutId, String packageName, UserHandle user) {
|
||||
ShortcutInfo shortcutInfo = getShortcutInfo(shortcutId, packageName, user);
|
||||
return shortcutInfo != null && shortcutInfo.isLongLived();
|
||||
}
|
||||
|
||||
private void logBubbleError(String key, String failureMessage) {
|
||||
if (DBG) {
|
||||
Log.w(TAG, "Bubble notification: " + key + " failed: " + failureMessage);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Whether an intent is properly configured to display in an {@link android.app.ActivityView}.
|
||||
*
|
||||
* @param context the context to use.
|
||||
* @param pendingIntent the pending intent of the bubble.
|
||||
* @param packageName the notification package name for this bubble.
|
||||
*/
|
||||
// Keep checks in sync with BubbleController#canLaunchInActivityView.
|
||||
@VisibleForTesting
|
||||
protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
|
||||
String packageName) {
|
||||
if (pendingIntent == null) {
|
||||
Log.w(TAG, "Unable to create bubble -- no intent");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Need escalated privileges to get the intent.
|
||||
final long token = Binder.clearCallingIdentity();
|
||||
Intent intent;
|
||||
try {
|
||||
intent = pendingIntent.getIntent();
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token);
|
||||
}
|
||||
|
||||
ActivityInfo info = intent != null
|
||||
? intent.resolveActivityInfo(context.getPackageManager(), 0)
|
||||
: null;
|
||||
if (info == null) {
|
||||
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED, packageName,
|
||||
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING);
|
||||
Log.w(TAG, "Unable to send as bubble -- couldn't find activity info for intent: "
|
||||
+ intent);
|
||||
return false;
|
||||
}
|
||||
if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
|
||||
FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED, packageName,
|
||||
BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE);
|
||||
Log.w(TAG, "Unable to send as bubble -- activity is not resizable for intent: "
|
||||
+ intent);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
private void doChannelWarningToast(CharSequence toastText) {
|
||||
Binder.withCleanCallingIdentity(() -> {
|
||||
@@ -6431,6 +6143,8 @@ public class NotificationManagerService extends SystemService {
|
||||
cancelGroupChildrenLocked(r, mCallingUid, mCallingPid, listenerName,
|
||||
mSendDelete, childrenFlagChecker);
|
||||
updateLightsLocked();
|
||||
mShortcutHelper.maybeListenForShortcutChangesForBubbles(r, true /* isRemoved */,
|
||||
mHandler);
|
||||
} else {
|
||||
// No notification was found, assume that it is snoozed and cancel it.
|
||||
if (mReason != REASON_SNOOZED) {
|
||||
@@ -6498,7 +6212,7 @@ public class NotificationManagerService extends SystemService {
|
||||
final String tag = n.getTag();
|
||||
|
||||
// We need to fix the notification up a little for bubbles
|
||||
updateNotificationBubbleFlags(r, pkg, callingUid, old, isAppForeground);
|
||||
updateNotificationBubbleFlags(r, isAppForeground);
|
||||
|
||||
// Handle grouped notifications and bail out early if we
|
||||
// can to avoid extracting signals.
|
||||
@@ -6668,6 +6382,10 @@ public class NotificationManagerService extends SystemService {
|
||||
+ n.getPackageName());
|
||||
}
|
||||
|
||||
mShortcutHelper.maybeListenForShortcutChangesForBubbles(r,
|
||||
false /* isRemoved */,
|
||||
mHandler);
|
||||
|
||||
maybeRecordInterruptionLocked(r);
|
||||
|
||||
// Log event to statsd
|
||||
@@ -7427,6 +7145,7 @@ public class NotificationManagerService extends SystemService {
|
||||
int[] visibilities = new int[N];
|
||||
boolean[] showBadges = new boolean[N];
|
||||
boolean[] allowBubbles = new boolean[N];
|
||||
boolean[] isBubble = new boolean[N];
|
||||
ArrayList<NotificationChannel> channelBefore = new ArrayList<>(N);
|
||||
ArrayList<String> groupKeyBefore = new ArrayList<>(N);
|
||||
ArrayList<ArrayList<String>> overridePeopleBefore = new ArrayList<>(N);
|
||||
@@ -7442,6 +7161,7 @@ public class NotificationManagerService extends SystemService {
|
||||
visibilities[i] = r.getPackageVisibilityOverride();
|
||||
showBadges[i] = r.canShowBadge();
|
||||
allowBubbles[i] = r.canBubble();
|
||||
isBubble[i] = r.getNotification().isBubbleNotification();
|
||||
channelBefore.add(r.getChannel());
|
||||
groupKeyBefore.add(r.getGroupKey());
|
||||
overridePeopleBefore.add(r.getPeopleOverride());
|
||||
@@ -7460,6 +7180,7 @@ public class NotificationManagerService extends SystemService {
|
||||
|| visibilities[i] != r.getPackageVisibilityOverride()
|
||||
|| showBadges[i] != r.canShowBadge()
|
||||
|| allowBubbles[i] != r.canBubble()
|
||||
|| isBubble[i] != r.getNotification().isBubbleNotification()
|
||||
|| !Objects.equals(channelBefore.get(i), r.getChannel())
|
||||
|| !Objects.equals(groupKeyBefore.get(i), r.getGroupKey())
|
||||
|| !Objects.equals(overridePeopleBefore.get(i), r.getPeopleOverride())
|
||||
@@ -8622,7 +8343,8 @@ public class NotificationManagerService extends SystemService {
|
||||
record.canBubble(),
|
||||
record.isInterruptive(),
|
||||
record.isConversation(),
|
||||
record.getShortcutInfo()
|
||||
record.getShortcutInfo(),
|
||||
record.getNotification().isBubbleNotification()
|
||||
);
|
||||
rankings.add(ranking);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.server.notification;
|
||||
|
||||
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED;
|
||||
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
|
||||
import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.content.pm.LauncherApps;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.os.Binder;
|
||||
import android.os.Handler;
|
||||
import android.os.UserHandle;
|
||||
|
||||
import com.android.internal.annotations.VisibleForTesting;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Helper for querying shortcuts.
|
||||
*/
|
||||
class ShortcutHelper {
|
||||
|
||||
/**
|
||||
* Listener to call when a shortcut we're tracking has been removed.
|
||||
*/
|
||||
interface ShortcutListener {
|
||||
void onShortcutRemoved(String key);
|
||||
}
|
||||
|
||||
private LauncherApps mLauncherAppsService;
|
||||
private ShortcutListener mShortcutListener;
|
||||
|
||||
// Key: packageName Value: <shortcutId, notifId>
|
||||
private HashMap<String, HashMap<String, String>> mActiveShortcutBubbles = new HashMap<>();
|
||||
private boolean mLauncherAppsCallbackRegistered;
|
||||
|
||||
// Bubbles can be created based on a shortcut, we need to listen for changes to
|
||||
// that shortcut so that we may update the bubble appropriately.
|
||||
private final LauncherApps.Callback mLauncherAppsCallback = new LauncherApps.Callback() {
|
||||
@Override
|
||||
public void onPackageRemoved(String packageName, UserHandle user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackageAdded(String packageName, UserHandle user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackageChanged(String packageName, UserHandle user) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackagesAvailable(String[] packageNames, UserHandle user,
|
||||
boolean replacing) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPackagesUnavailable(String[] packageNames, UserHandle user,
|
||||
boolean replacing) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShortcutsChanged(@NonNull String packageName,
|
||||
@NonNull List<ShortcutInfo> shortcuts, @NonNull UserHandle user) {
|
||||
HashMap<String, String> shortcutBubbles = mActiveShortcutBubbles.get(packageName);
|
||||
ArrayList<String> bubbleKeysToRemove = new ArrayList<>();
|
||||
if (shortcutBubbles != null) {
|
||||
// If we can't find one of our bubbles in the shortcut list, that bubble needs
|
||||
// to be removed.
|
||||
for (String shortcutId : shortcutBubbles.keySet()) {
|
||||
boolean foundShortcut = false;
|
||||
for (int i = 0; i < shortcuts.size(); i++) {
|
||||
if (shortcuts.get(i).getId().equals(shortcutId)) {
|
||||
foundShortcut = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundShortcut) {
|
||||
bubbleKeysToRemove.add(shortcutBubbles.get(shortcutId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Let NoMan know about the updates
|
||||
for (int i = 0; i < bubbleKeysToRemove.size(); i++) {
|
||||
// update flag bubble
|
||||
String bubbleKey = bubbleKeysToRemove.get(i);
|
||||
if (mShortcutListener != null) {
|
||||
mShortcutListener.onShortcutRemoved(bubbleKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ShortcutHelper(LauncherApps launcherApps, ShortcutListener listener) {
|
||||
mLauncherAppsService = launcherApps;
|
||||
mShortcutListener = listener;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void setLauncherApps(LauncherApps launcherApps) {
|
||||
mLauncherAppsService = launcherApps;
|
||||
}
|
||||
|
||||
ShortcutInfo getShortcutInfo(String shortcutId, String packageName, UserHandle user) {
|
||||
if (mLauncherAppsService == null) {
|
||||
return null;
|
||||
}
|
||||
final long token = Binder.clearCallingIdentity();
|
||||
try {
|
||||
if (shortcutId == null || packageName == null || user == null) {
|
||||
return null;
|
||||
}
|
||||
LauncherApps.ShortcutQuery query = new LauncherApps.ShortcutQuery();
|
||||
query.setPackage(packageName);
|
||||
query.setShortcutIds(Arrays.asList(shortcutId));
|
||||
query.setQueryFlags(FLAG_MATCH_DYNAMIC | FLAG_MATCH_PINNED | FLAG_MATCH_CACHED);
|
||||
List<ShortcutInfo> shortcuts = mLauncherAppsService.getShortcuts(query, user);
|
||||
return shortcuts != null && shortcuts.size() > 0
|
||||
? shortcuts.get(0)
|
||||
: null;
|
||||
} finally {
|
||||
Binder.restoreCallingIdentity(token);
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasValidShortcutInfo(String shortcutId, String packageName,
|
||||
UserHandle user) {
|
||||
ShortcutInfo shortcutInfo = getShortcutInfo(shortcutId, packageName, user);
|
||||
return shortcutInfo != null && shortcutInfo.isLongLived();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut based bubbles require some extra work to listen for shortcut changes.
|
||||
*
|
||||
* @param r the notification record to check
|
||||
* @param removedNotification true if this notification is being removed
|
||||
* @param handler handler to register the callback with
|
||||
*/
|
||||
void maybeListenForShortcutChangesForBubbles(NotificationRecord r, boolean removedNotification,
|
||||
Handler handler) {
|
||||
final String shortcutId = r.getNotification().getBubbleMetadata() != null
|
||||
? r.getNotification().getBubbleMetadata().getShortcutId()
|
||||
: null;
|
||||
if (shortcutId == null) {
|
||||
return;
|
||||
}
|
||||
if (r.getNotification().isBubbleNotification() && !removedNotification) {
|
||||
// Must track shortcut based bubbles in case the shortcut is removed
|
||||
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
|
||||
r.getSbn().getPackageName());
|
||||
if (packageBubbles == null) {
|
||||
packageBubbles = new HashMap<>();
|
||||
}
|
||||
packageBubbles.put(shortcutId, r.getKey());
|
||||
mActiveShortcutBubbles.put(r.getSbn().getPackageName(), packageBubbles);
|
||||
if (!mLauncherAppsCallbackRegistered) {
|
||||
mLauncherAppsService.registerCallback(mLauncherAppsCallback, handler);
|
||||
mLauncherAppsCallbackRegistered = true;
|
||||
}
|
||||
} else {
|
||||
// No longer track shortcut
|
||||
HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
|
||||
r.getSbn().getPackageName());
|
||||
if (packageBubbles != null) {
|
||||
packageBubbles.remove(shortcutId);
|
||||
}
|
||||
if (packageBubbles != null && packageBubbles.isEmpty()) {
|
||||
mActiveShortcutBubbles.remove(r.getSbn().getPackageName());
|
||||
}
|
||||
if (mLauncherAppsCallbackRegistered && mActiveShortcutBubbles.isEmpty()) {
|
||||
mLauncherAppsService.unregisterCallback(mLauncherAppsCallback);
|
||||
mLauncherAppsCallbackRegistered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import android.app.INotificationManager;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Person;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -191,7 +190,8 @@ public class NotificationListenerServiceTest extends UiServiceTestCase {
|
||||
tweak.canBubble(),
|
||||
tweak.visuallyInterruptive(),
|
||||
tweak.isConversation(),
|
||||
tweak.getShortcutInfo()
|
||||
tweak.getShortcutInfo(),
|
||||
tweak.isBubble()
|
||||
);
|
||||
assertNotEquals(nru, nru2);
|
||||
}
|
||||
@@ -270,7 +270,8 @@ public class NotificationListenerServiceTest extends UiServiceTestCase {
|
||||
canBubble(i),
|
||||
visuallyInterruptive(i),
|
||||
isConversation(i),
|
||||
getShortcutInfo(i)
|
||||
getShortcutInfo(i),
|
||||
isBubble(i)
|
||||
);
|
||||
rankings[i] = ranking;
|
||||
}
|
||||
@@ -394,6 +395,10 @@ public class NotificationListenerServiceTest extends UiServiceTestCase {
|
||||
return si;
|
||||
}
|
||||
|
||||
private boolean isBubble(int index) {
|
||||
return index % 4 == 0;
|
||||
}
|
||||
|
||||
private void assertActionsEqual(
|
||||
List<Notification.Action> expecteds, List<Notification.Action> actuals) {
|
||||
assertEquals(expecteds.size(), actuals.size());
|
||||
|
||||
@@ -202,6 +202,8 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
private TestableNotificationManagerService mService;
|
||||
private INotificationManager mBinderService;
|
||||
private NotificationManagerInternal mInternalService;
|
||||
private TestableBubbleChecker mTestableBubbleChecker;
|
||||
private ShortcutHelper mShortcutHelper;
|
||||
@Mock
|
||||
private IPackageManager mPackageManager;
|
||||
@Mock
|
||||
@@ -284,6 +286,10 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
super(context, logger, notificationInstanceIdSequence);
|
||||
}
|
||||
|
||||
RankingHelper getRankingHelper() {
|
||||
return mRankingHelper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isCallingUidSystem() {
|
||||
countSystemChecks++;
|
||||
@@ -335,6 +341,14 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
interface NotificationAssistantAccessGrantedCallback {
|
||||
void onGranted(ComponentName assistant, int userId, boolean granted);
|
||||
}
|
||||
}
|
||||
|
||||
private class TestableBubbleChecker extends BubbleExtractor.BubbleChecker {
|
||||
|
||||
TestableBubbleChecker(Context context, ShortcutHelper helper, RankingConfig config,
|
||||
ActivityManager manager) {
|
||||
super(context, helper, config, manager);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
|
||||
@@ -446,7 +460,16 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
mService.onBootPhase(SystemService.PHASE_SYSTEM_SERVICES_READY);
|
||||
|
||||
mService.setAudioManager(mAudioManager);
|
||||
mService.setLauncherApps(mLauncherApps);
|
||||
|
||||
mShortcutHelper = mService.getShortcutHelper();
|
||||
mShortcutHelper.setLauncherApps(mLauncherApps);
|
||||
|
||||
// Set the testable bubble extractor
|
||||
RankingHelper rankingHelper = mService.getRankingHelper();
|
||||
BubbleExtractor extractor = rankingHelper.findExtractor(BubbleExtractor.class);
|
||||
mTestableBubbleChecker = new TestableBubbleChecker(mContext, mShortcutHelper,
|
||||
mService.mPreferencesHelper, mActivityManager);
|
||||
extractor.setBubbleChecker(mTestableBubbleChecker);
|
||||
|
||||
// Tests call directly into the Binder.
|
||||
mBinderService = mService.getBinderService();
|
||||
@@ -5776,6 +5799,9 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
|
||||
@Test
|
||||
public void testOnBubbleNotificationSuppressionChanged() throws Exception {
|
||||
// Bubbles are allowed!
|
||||
setUpPrefsForBubbles(PKG, mUid, true /* global */, true /* app */, true /* channel */);
|
||||
|
||||
// Bubble notification
|
||||
NotificationRecord nr = generateMessageBubbleNotifRecord(mTestNotificationChannel, "tag");
|
||||
|
||||
@@ -6177,8 +6203,10 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
assertTrue(notif.isBubbleNotification());
|
||||
|
||||
// Test: Remove the shortcut
|
||||
when(mLauncherApps.getShortcuts(any(), any())).thenReturn(null);
|
||||
launcherAppsCallback.getValue().onShortcutsChanged(PKG, Collections.emptyList(),
|
||||
new UserHandle(mUid));
|
||||
waitForIdle();
|
||||
|
||||
// Verify:
|
||||
|
||||
@@ -6191,6 +6219,58 @@ public class NotificationManagerServiceTest extends UiServiceTestCase {
|
||||
assertFalse(notif2.isBubbleNotification());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testNotificationBubbles_shortcut_stopListeningWhenNotifRemoved()
|
||||
throws RemoteException {
|
||||
// Bubbles are allowed!
|
||||
setUpPrefsForBubbles(PKG, mUid, true /* global */, true /* app */, true /* channel */);
|
||||
|
||||
ArgumentCaptor<LauncherApps.Callback> launcherAppsCallback =
|
||||
ArgumentCaptor.forClass(LauncherApps.Callback.class);
|
||||
|
||||
// Messaging notification with shortcut info
|
||||
Notification.BubbleMetadata metadata =
|
||||
getBubbleMetadataBuilder().createShortcutBubble("someshortcutId").build();
|
||||
Notification.Builder nb = getMessageStyleNotifBuilder(false /* addDefaultMetadata */,
|
||||
null /* groupKey */, false /* isSummary */);
|
||||
nb.setBubbleMetadata(metadata);
|
||||
StatusBarNotification sbn = new StatusBarNotification(PKG, PKG, 1,
|
||||
"tag", mUid, 0, nb.build(), new UserHandle(mUid), null, 0);
|
||||
NotificationRecord nr = new NotificationRecord(mContext, sbn, mTestNotificationChannel);
|
||||
|
||||
// Pretend the shortcut exists
|
||||
List<ShortcutInfo> shortcutInfos = new ArrayList<>();
|
||||
ShortcutInfo info = mock(ShortcutInfo.class);
|
||||
when(info.isLongLived()).thenReturn(true);
|
||||
shortcutInfos.add(info);
|
||||
when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos);
|
||||
|
||||
// Test: Send the bubble notification
|
||||
mBinderService.enqueueNotificationWithTag(PKG, PKG, nr.getSbn().getTag(),
|
||||
nr.getSbn().getId(), nr.getSbn().getNotification(), nr.getSbn().getUserId());
|
||||
waitForIdle();
|
||||
|
||||
// Verify:
|
||||
|
||||
// Make sure we register the callback for shortcut changes
|
||||
verify(mLauncherApps, times(1)).registerCallback(launcherAppsCallback.capture(), any());
|
||||
|
||||
// yes allowed, yes messaging w/shortcut, yes bubble
|
||||
Notification notif = mService.getNotificationRecord(nr.getSbn().getKey()).getNotification();
|
||||
assertTrue(notif.isBubbleNotification());
|
||||
|
||||
// Test: Remove the notification
|
||||
mBinderService.cancelNotificationWithTag(PKG, PKG, nr.getSbn().getTag(),
|
||||
nr.getSbn().getId(), nr.getSbn().getUserId());
|
||||
waitForIdle();
|
||||
|
||||
// Verify:
|
||||
|
||||
// Make sure callback is unregistered
|
||||
verify(mLauncherApps, times(1)).unregisterCallback(launcherAppsCallback.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNotificationBubbles_bubbleChildrenStay_whenGroupSummaryDismissed()
|
||||
throws Exception {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.server.notification;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.content.pm.LauncherApps;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.os.UserHandle;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.test.suitebuilder.annotation.SmallTest;
|
||||
import android.testing.TestableLooper;
|
||||
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import com.android.server.UiServiceTestCase;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@SmallTest
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@TestableLooper.RunWithLooper
|
||||
public class ShortcutHelperTest extends UiServiceTestCase {
|
||||
|
||||
private static final String SHORTCUT_ID = "shortcut";
|
||||
private static final String PKG = "pkg";
|
||||
private static final String KEY = "key";
|
||||
|
||||
@Mock
|
||||
LauncherApps mLauncherApps;
|
||||
@Mock
|
||||
ShortcutHelper.ShortcutListener mShortcutListener;
|
||||
@Mock
|
||||
NotificationRecord mNr;
|
||||
@Mock
|
||||
Notification mNotif;
|
||||
@Mock
|
||||
StatusBarNotification mSbn;
|
||||
@Mock
|
||||
Notification.BubbleMetadata mBubbleMetadata;
|
||||
|
||||
ShortcutHelper mShortcutHelper;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
MockitoAnnotations.initMocks(this);
|
||||
|
||||
mShortcutHelper = new ShortcutHelper(mLauncherApps, mShortcutListener);
|
||||
when(mNr.getKey()).thenReturn(KEY);
|
||||
when(mNr.getSbn()).thenReturn(mSbn);
|
||||
when(mSbn.getPackageName()).thenReturn(PKG);
|
||||
when(mNr.getNotification()).thenReturn(mNotif);
|
||||
when(mNotif.getBubbleMetadata()).thenReturn(mBubbleMetadata);
|
||||
when(mBubbleMetadata.getShortcutId()).thenReturn(SHORTCUT_ID);
|
||||
}
|
||||
|
||||
private LauncherApps.Callback addShortcutBubbleAndVerifyListener() {
|
||||
when(mNotif.isBubbleNotification()).thenReturn(true);
|
||||
|
||||
mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
|
||||
false /* removed */,
|
||||
null /* handler */);
|
||||
|
||||
ArgumentCaptor<LauncherApps.Callback> launcherAppsCallback =
|
||||
ArgumentCaptor.forClass(LauncherApps.Callback.class);
|
||||
|
||||
verify(mLauncherApps, times(1)).registerCallback(
|
||||
launcherAppsCallback.capture(), any());
|
||||
return launcherAppsCallback.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBubbleAdded_listenedAdded() {
|
||||
addShortcutBubbleAndVerifyListener();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBubbleRemoved_listenerRemoved() {
|
||||
// First set it up to listen
|
||||
addShortcutBubbleAndVerifyListener();
|
||||
|
||||
// Then remove the notif
|
||||
mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
|
||||
true /* removed */,
|
||||
null /* handler */);
|
||||
|
||||
verify(mLauncherApps, times(1)).unregisterCallback(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBubbleNoLongerBubble_listenerRemoved() {
|
||||
// First set it up to listen
|
||||
addShortcutBubbleAndVerifyListener();
|
||||
|
||||
// Then make it not a bubble
|
||||
when(mNotif.isBubbleNotification()).thenReturn(false);
|
||||
mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
|
||||
false /* removed */,
|
||||
null /* handler */);
|
||||
|
||||
verify(mLauncherApps, times(1)).unregisterCallback(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testListenerNotifiedOnShortcutRemoved() {
|
||||
LauncherApps.Callback callback = addShortcutBubbleAndVerifyListener();
|
||||
|
||||
List<ShortcutInfo> shortcutInfos = new ArrayList<>();
|
||||
when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos);
|
||||
|
||||
callback.onShortcutsChanged(PKG, shortcutInfos, mock(UserHandle.class));
|
||||
verify(mShortcutListener).onShortcutRemoved(mNr.getKey());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user