diff options
13 files changed, 1399 insertions, 79 deletions
diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java index 90aba2f73ef9..1eeaa7c0c939 100644 --- a/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java @@ -87,6 +87,7 @@ import com.android.systemui.plugins.qs.QS; import com.android.systemui.qs.car.CarQSFragment; import com.android.systemui.shared.system.ActivityManagerWrapper; import com.android.systemui.shared.system.TaskStackChangeListener; +import com.android.systemui.statusbar.FeatureFlags; import com.android.systemui.statusbar.FlingAnimationUtils; import com.android.systemui.statusbar.NavigationBarController; import com.android.systemui.statusbar.NotificationListener; @@ -102,7 +103,7 @@ import com.android.systemui.statusbar.car.hvac.HvacController; import com.android.systemui.statusbar.car.hvac.TemperatureView; import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier; import com.android.systemui.statusbar.notification.DynamicPrivacyController; -import com.android.systemui.statusbar.notification.NotifPipelineInitializer; +import com.android.systemui.statusbar.notification.NewNotifPipeline; import com.android.systemui.statusbar.notification.NotificationAlertingManager; import com.android.systemui.statusbar.notification.NotificationEntryManager; import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider; @@ -140,6 +141,8 @@ import java.util.Map; import javax.inject.Inject; import javax.inject.Named; +import dagger.Lazy; + /** * A status bar (and navigation bar) tailored for the automotive use case. */ @@ -252,6 +255,7 @@ public class CarStatusBar extends StatusBar implements CarBatteryController.Batt @Inject public CarStatusBar( Context context, + FeatureFlags featureFlags, LightBarController lightBarController, AutoHideController autoHideController, KeyguardUpdateMonitor keyguardUpdateMonitor, @@ -266,7 +270,7 @@ public class CarStatusBar extends StatusBar implements CarBatteryController.Batt DynamicPrivacyController dynamicPrivacyController, BypassHeadsUpNotifier bypassHeadsUpNotifier, @Named(ALLOW_NOTIFICATION_LONG_PRESS_NAME) boolean allowNotificationLongPress, - NotifPipelineInitializer notifPipelineInitializer, + Lazy<NewNotifPipeline> newNotifPipeline, FalsingManager falsingManager, BroadcastDispatcher broadcastDispatcher, RemoteInputQuickSettingsDisabler remoteInputQuickSettingsDisabler, @@ -309,6 +313,7 @@ public class CarStatusBar extends StatusBar implements CarBatteryController.Batt DozeParameters dozeParameters) { super( context, + featureFlags, lightBarController, autoHideController, keyguardUpdateMonitor, @@ -323,7 +328,7 @@ public class CarStatusBar extends StatusBar implements CarBatteryController.Batt dynamicPrivacyController, bypassHeadsUpNotifier, allowNotificationLongPress, - notifPipelineInitializer, + newNotifPipeline, falsingManager, broadcastDispatcher, remoteInputQuickSettingsDisabler, diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java b/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java new file mode 100644 index 000000000000..f91341f8f4ea --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/FeatureFlags.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar; + +import android.annotation.NonNull; +import android.os.Handler; +import android.os.HandlerExecutor; +import android.os.Looper; +import android.provider.DeviceConfig; +import android.util.ArrayMap; + +import java.util.Map; + +import javax.inject.Inject; + +/** + * Class to manage simple DeviceConfig-based feature flags. + * + * To enable or disable a flag, run: + * + * {@code + * $ adb shell device_config put systemui <key> <true|false> +* } + * + * You will probably need to @{$ adb reboot} afterwards in order for the code to pick up the change. + */ +public class FeatureFlags { + private final Map<String, Boolean> mCachedDeviceConfigFlags = new ArrayMap<>(); + + @Inject + public FeatureFlags() { + DeviceConfig.addOnPropertiesChangedListener( + "systemui", + new HandlerExecutor(new Handler(Looper.getMainLooper())), + this::onPropertiesChanged); + } + + public boolean isNewNotifPipelineEnabled() { + return getDeviceConfigFlag("notification.newpipeline.enabled", false); + } + + private void onPropertiesChanged(@NonNull DeviceConfig.Properties properties) { + synchronized (mCachedDeviceConfigFlags) { + for (String key : properties.getKeyset()) { + mCachedDeviceConfigFlags.remove(key); + } + } + } + + private boolean getDeviceConfigFlag(String key, boolean defaultValue) { + synchronized (mCachedDeviceConfigFlags) { + Boolean flag = mCachedDeviceConfigFlags.get(key); + if (flag == null) { + flag = DeviceConfig.getBoolean("systemui", key, defaultValue); + mCachedDeviceConfigFlags.put(key, flag); + } + return flag; + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NewNotifPipeline.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NewNotifPipeline.java new file mode 100644 index 000000000000..31921a436747 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NewNotifPipeline.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification; + +import android.util.Log; + +import com.android.systemui.statusbar.NotificationListener; +import com.android.systemui.statusbar.notification.collection.NotifCollection; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Initialization code for the new notification pipeline. + */ +@Singleton +public class NewNotifPipeline { + private final NotifCollection mNotifCollection; + + @Inject + public NewNotifPipeline( + NotifCollection notifCollection) { + mNotifCollection = notifCollection; + } + + /** Hooks the new pipeline up to NotificationManager */ + public void initialize( + NotificationListener notificationService) { + mNotifCollection.attach(notificationService); + + Log.d(TAG, "Notif pipeline initialized"); + } + + private static final String TAG = "NewNotifPipeline"; +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotifPipelineInitializer.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotifPipelineInitializer.java deleted file mode 100644 index df70828a46be..000000000000 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotifPipelineInitializer.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.statusbar.notification; - -import android.service.notification.NotificationListenerService; -import android.service.notification.StatusBarNotification; -import android.util.Log; - -import com.android.systemui.statusbar.NotificationListener; - -import javax.inject.Inject; - -/** - * Initialization code for the new notification pipeline. - */ -public class NotifPipelineInitializer { - - @Inject - public NotifPipelineInitializer() { - } - - public void initialize( - NotificationListener notificationService) { - - // TODO Put real code here - notificationService.setDownstreamListener(new NotificationListener.NotifServiceListener() { - @Override - public void onNotificationPosted(StatusBarNotification sbn, - NotificationListenerService.RankingMap rankingMap) { - Log.d(TAG, "onNotificationPosted " + sbn.getKey()); - } - - @Override - public void onNotificationRemoved(StatusBarNotification sbn, - NotificationListenerService.RankingMap rankingMap) { - Log.d(TAG, "onNotificationRemoved " + sbn.getKey()); - } - - @Override - public void onNotificationRemoved(StatusBarNotification sbn, - NotificationListenerService.RankingMap rankingMap, int reason) { - Log.d(TAG, "onNotificationRemoved " + sbn.getKey()); - } - - @Override - public void onNotificationRankingUpdate( - NotificationListenerService.RankingMap rankingMap) { - Log.d(TAG, "onNotificationRankingUpdate"); - } - }); - } - - private static final String TAG = "NotifInitializer"; -} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/DismissedByUserStats.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/DismissedByUserStats.java new file mode 100644 index 000000000000..ecce6ea1b211 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/DismissedByUserStats.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.collection; + +import android.service.notification.NotificationStats.DismissalSentiment; +import android.service.notification.NotificationStats.DismissalSurface; + +import com.android.internal.statusbar.NotificationVisibility; + +/** Information that must be supplied when dismissing a notification on the behalf of the user. */ +public class DismissedByUserStats { + public final @DismissalSurface int dismissalSurface; + public final @DismissalSentiment int dismissalSentiment; + public final NotificationVisibility notificationVisibility; + + public DismissedByUserStats( + @DismissalSurface int dismissalSurface, + @DismissalSentiment int dismissalSentiment, + NotificationVisibility notificationVisibility) { + this.dismissalSurface = dismissalSurface; + this.dismissalSentiment = dismissalSentiment; + this.notificationVisibility = notificationVisibility; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java new file mode 100644 index 000000000000..3203c30ce422 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java @@ -0,0 +1,426 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.collection; + +import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL; +import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL_ALL; +import static android.service.notification.NotificationListenerService.REASON_CANCEL_ALL; +import static android.service.notification.NotificationListenerService.REASON_CHANNEL_BANNED; +import static android.service.notification.NotificationListenerService.REASON_CLICK; +import static android.service.notification.NotificationListenerService.REASON_ERROR; +import static android.service.notification.NotificationListenerService.REASON_GROUP_OPTIMIZATION; +import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED; +import static android.service.notification.NotificationListenerService.REASON_LISTENER_CANCEL; +import static android.service.notification.NotificationListenerService.REASON_LISTENER_CANCEL_ALL; +import static android.service.notification.NotificationListenerService.REASON_PACKAGE_BANNED; +import static android.service.notification.NotificationListenerService.REASON_PACKAGE_CHANGED; +import static android.service.notification.NotificationListenerService.REASON_PACKAGE_SUSPENDED; +import static android.service.notification.NotificationListenerService.REASON_PROFILE_TURNED_OFF; +import static android.service.notification.NotificationListenerService.REASON_SNOOZED; +import static android.service.notification.NotificationListenerService.REASON_TIMEOUT; +import static android.service.notification.NotificationListenerService.REASON_UNAUTOBUNDLED; +import static android.service.notification.NotificationListenerService.REASON_USER_STOPPED; + +import static com.android.internal.util.Preconditions.checkNotNull; + +import android.annotation.IntDef; +import android.annotation.MainThread; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.os.RemoteException; +import android.service.notification.NotificationListenerService.Ranking; +import android.service.notification.NotificationListenerService.RankingMap; +import android.service.notification.StatusBarNotification; +import android.util.ArrayMap; +import android.util.Log; + +import com.android.internal.statusbar.IStatusBarService; +import com.android.systemui.statusbar.NotificationListener; +import com.android.systemui.statusbar.NotificationListener.NotifServiceListener; +import com.android.systemui.util.Assert; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Keeps a record of all of the "active" notifications, i.e. the notifications that are currently + * posted to the phone. This collection is unsorted, ungrouped, and unfiltered. Just because a + * notification appears in this collection doesn't mean that it's currently present in the shade + * (notifications can be hidden for a variety of reasons). Code that cares about what notifications + * are *visible* right now should register listeners later in the pipeline. + * + * Each notification is represented by a {@link NotificationEntry}, which is itself made up of two + * parts: a {@link StatusBarNotification} and a {@link Ranking}. When notifications are updated, + * their underlying SBNs and Rankings are swapped out, but the enclosing NotificationEntry (and its + * associated key) remain the same. In general, an SBN can only be updated when the notification is + * reposted by the source app; Rankings are updated much more often, usually every time there is an + * update from any kind from NotificationManager. + * + * In general, this collection closely mirrors the list maintained by NotificationManager, but it + * can occasionally diverge due to lifetime extenders (see + * {@link #addNotificationLifetimeExtender(NotifLifetimeExtender)}). + * + * Interested parties can register listeners + * ({@link #addCollectionListener(NotifCollectionListener)}) to be informed when notifications are + * added, updated, or removed. + */ +@MainThread +@Singleton +public class NotifCollection { + private final IStatusBarService mStatusBarService; + + private final Map<String, NotificationEntry> mNotificationSet = new ArrayMap<>(); + private final Collection<NotificationEntry> mReadOnlyNotificationSet = + Collections.unmodifiableCollection(mNotificationSet.values()); + + @Nullable private NotifListBuilder mListBuilder; + private final List<NotifCollectionListener> mNotifCollectionListeners = new ArrayList<>(); + private final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>(); + + private boolean mAttached = false; + private boolean mAmDispatchingToOtherCode; + + @Inject + public NotifCollection(IStatusBarService statusBarService) { + Assert.isMainThread(); + mStatusBarService = statusBarService; + } + + /** Initializes the NotifCollection and registers it to receive notification events. */ + public void attach(NotificationListener listenerService) { + Assert.isMainThread(); + if (mAttached) { + throw new RuntimeException("attach() called twice"); + } + mAttached = true; + + listenerService.setDownstreamListener(mNotifServiceListener); + } + + /** + * Sets the class responsible for converting the collection into the list of currently-visible + * notifications. + */ + public void setListBuilder(NotifListBuilder listBuilder) { + Assert.isMainThread(); + mListBuilder = listBuilder; + } + + /** + * Returns the list of "active" notifications, i.e. the notifications that are currently posted + * to the phone. In general, this tracks closely to the list maintained by NotificationManager, + * but it can diverge slightly due to lifetime extenders. + * + * The returned list is read-only, unsorted, unfiltered, and ungrouped. + */ + public Collection<NotificationEntry> getNotifs() { + Assert.isMainThread(); + return mReadOnlyNotificationSet; + } + + /** + * Registers a listener to be informed when notifications are added, removed or updated. + */ + public void addCollectionListener(NotifCollectionListener listener) { + Assert.isMainThread(); + mNotifCollectionListeners.add(listener); + } + + /** + * Registers a lifetime extender. Lifetime extenders can cause notifications that have been + * dismissed or retracted to be temporarily retained in the collection. + */ + public void addNotificationLifetimeExtender(NotifLifetimeExtender extender) { + Assert.isMainThread(); + checkForReentrantCall(); + if (mLifetimeExtenders.contains(extender)) { + throw new IllegalArgumentException("Extender " + extender + " already added."); + } + mLifetimeExtenders.add(extender); + extender.setCallback(this::onEndLifetimeExtension); + } + + /** + * Dismiss a notification on behalf of the user. + */ + public void dismissNotification( + NotificationEntry entry, + @CancellationReason int reason, + @NonNull DismissedByUserStats stats) { + Assert.isMainThread(); + checkNotNull(stats); + checkForReentrantCall(); + + removeNotification(entry.key(), null, reason, stats); + } + + private void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) { + Assert.isMainThread(); + + NotificationEntry entry = mNotificationSet.get(sbn.getKey()); + + if (entry == null) { + // A new notification! + Log.d(TAG, "POSTED " + sbn.getKey()); + + entry = new NotificationEntry(sbn, requireRanking(rankingMap, sbn.getKey())); + mNotificationSet.put(sbn.getKey(), entry); + applyRanking(rankingMap); + + dispatchOnEntryAdded(entry); + + } else { + // Update to an existing entry + Log.d(TAG, "UPDATED " + sbn.getKey()); + + // Notification is updated so it is essentially re-added and thus alive again. Don't + // need to keep its lifetime extended. + cancelLifetimeExtension(entry); + + entry.setNotification(sbn); + applyRanking(rankingMap); + + dispatchOnEntryUpdated(entry); + } + + rebuildList(); + } + + private void onNotificationRemoved( + StatusBarNotification sbn, + @Nullable RankingMap rankingMap, + int reason) { + Assert.isMainThread(); + Log.d(TAG, "REMOVED " + sbn.getKey() + " reason=" + reason); + removeNotification(sbn.getKey(), rankingMap, reason, null); + } + + private void onNotificationRankingUpdate(RankingMap rankingMap) { + Assert.isMainThread(); + applyRanking(rankingMap); + rebuildList(); + } + + private void removeNotification( + String key, + @Nullable RankingMap rankingMap, + @CancellationReason int reason, + DismissedByUserStats dismissedByUserStats) { + + NotificationEntry entry = mNotificationSet.get(key); + if (entry == null) { + throw new IllegalStateException("No notification to remove with key " + key); + } + + entry.mLifetimeExtenders.clear(); + mAmDispatchingToOtherCode = true; + for (NotifLifetimeExtender extender : mLifetimeExtenders) { + if (extender.shouldExtendLifetime(entry, reason)) { + entry.mLifetimeExtenders.add(extender); + } + } + mAmDispatchingToOtherCode = false; + + if (!isLifetimeExtended(entry)) { + mNotificationSet.remove(entry.key()); + + if (dismissedByUserStats != null) { + try { + mStatusBarService.onNotificationClear( + entry.sbn().getPackageName(), + entry.sbn().getTag(), + entry.sbn().getId(), + entry.sbn().getUser().getIdentifier(), + entry.sbn().getKey(), + dismissedByUserStats.dismissalSurface, + dismissedByUserStats.dismissalSentiment, + dismissedByUserStats.notificationVisibility); + } catch (RemoteException e) { + // system process is dead if we're here. + } + } + + if (rankingMap != null) { + applyRanking(rankingMap); + } + + dispatchOnEntryRemoved(entry, reason, dismissedByUserStats != null /* removedByUser */); + } + + rebuildList(); + } + + private void applyRanking(RankingMap rankingMap) { + for (NotificationEntry entry : mNotificationSet.values()) { + if (!isLifetimeExtended(entry)) { + Ranking ranking = requireRanking(rankingMap, entry.key()); + entry.setRanking(ranking); + } + } + } + + private void rebuildList() { + if (mListBuilder != null) { + mListBuilder.onBuildList(mReadOnlyNotificationSet); + } + } + + private void onEndLifetimeExtension(NotifLifetimeExtender extender, NotificationEntry entry) { + Assert.isMainThread(); + if (!mAttached) { + return; + } + checkForReentrantCall(); + + if (!entry.mLifetimeExtenders.remove(extender)) { + throw new IllegalStateException( + String.format( + "Cannot end lifetime extension for extender \"%s\" (%s)", + extender.getName(), + extender)); + } + + if (!isLifetimeExtended(entry)) { + // TODO: This doesn't need to be undefined -- we can set either EXTENDER_EXPIRED or + // save the original reason + removeNotification(entry.key(), null, REASON_UNKNOWN, null); + } + } + + private void cancelLifetimeExtension(NotificationEntry entry) { + mAmDispatchingToOtherCode = true; + for (NotifLifetimeExtender extender : entry.mLifetimeExtenders) { + extender.cancelLifetimeExtension(entry); + } + mAmDispatchingToOtherCode = false; + entry.mLifetimeExtenders.clear(); + } + + private boolean isLifetimeExtended(NotificationEntry entry) { + return entry.mLifetimeExtenders.size() > 0; + } + + private void checkForReentrantCall() { + if (mAmDispatchingToOtherCode) { + throw new IllegalStateException("Reentrant call detected"); + } + } + + private static Ranking requireRanking(RankingMap rankingMap, String key) { + // TODO: Modify RankingMap so that we don't have to make a copy here + Ranking ranking = new Ranking(); + if (!rankingMap.getRanking(key, ranking)) { + throw new IllegalArgumentException("Ranking map doesn't contain key: " + key); + } + return ranking; + } + + private void dispatchOnEntryAdded(NotificationEntry entry) { + mAmDispatchingToOtherCode = true; + if (mListBuilder != null) { + mListBuilder.onBeginDispatchToListeners(); + } + for (NotifCollectionListener listener : mNotifCollectionListeners) { + listener.onEntryAdded(entry); + } + mAmDispatchingToOtherCode = false; + } + + private void dispatchOnEntryUpdated(NotificationEntry entry) { + mAmDispatchingToOtherCode = true; + if (mListBuilder != null) { + mListBuilder.onBeginDispatchToListeners(); + } + for (NotifCollectionListener listener : mNotifCollectionListeners) { + listener.onEntryUpdated(entry); + } + mAmDispatchingToOtherCode = false; + } + + private void dispatchOnEntryRemoved( + NotificationEntry entry, + @CancellationReason int reason, + boolean removedByUser) { + mAmDispatchingToOtherCode = true; + if (mListBuilder != null) { + mListBuilder.onBeginDispatchToListeners(); + } + for (NotifCollectionListener listener : mNotifCollectionListeners) { + listener.onEntryRemoved(entry, reason, removedByUser); + } + mAmDispatchingToOtherCode = false; + } + + private final NotifServiceListener mNotifServiceListener = new NotifServiceListener() { + @Override + public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) { + NotifCollection.this.onNotificationPosted(sbn, rankingMap); + } + + @Override + public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap) { + NotifCollection.this.onNotificationRemoved(sbn, rankingMap, REASON_UNKNOWN); + } + + @Override + public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, + int reason) { + NotifCollection.this.onNotificationRemoved(sbn, rankingMap, reason); + } + + @Override + public void onNotificationRankingUpdate(RankingMap rankingMap) { + NotifCollection.this.onNotificationRankingUpdate(rankingMap); + } + }; + + private static final String TAG = "NotifCollection"; + + @IntDef(prefix = { "REASON_" }, value = { + REASON_UNKNOWN, + REASON_CLICK, + REASON_CANCEL_ALL, + REASON_ERROR, + REASON_PACKAGE_CHANGED, + REASON_USER_STOPPED, + REASON_PACKAGE_BANNED, + REASON_APP_CANCEL, + REASON_APP_CANCEL_ALL, + REASON_LISTENER_CANCEL, + REASON_LISTENER_CANCEL_ALL, + REASON_GROUP_SUMMARY_CANCELED, + REASON_GROUP_OPTIMIZATION, + REASON_PACKAGE_SUSPENDED, + REASON_PROFILE_TURNED_OFF, + REASON_UNAUTOBUNDLED, + REASON_CHANNEL_BANNED, + REASON_SNOOZED, + REASON_TIMEOUT, + }) + @Retention(RetentionPolicy.SOURCE) + @interface CancellationReason {} + + public static final int REASON_UNKNOWN = 0; +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollectionListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollectionListener.java new file mode 100644 index 000000000000..032620e14336 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollectionListener.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.collection; + +import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason; + +/** + * Listener interface for {@link NotifCollection}. + */ +public interface NotifCollectionListener { + /** + * Called whenever a notification with a new key is posted. + */ + default void onEntryAdded(NotificationEntry entry) { + } + + /** + * Called whenever a notification with the same key as an existing notification is posted. By + * the time this listener is called, the entry's SBN and Ranking will already have been updated. + */ + default void onEntryUpdated(NotificationEntry entry) { + } + + /** + * Called immediately after a notification has been removed from the collection. + */ + default void onEntryRemoved( + NotificationEntry entry, + @CancellationReason int reason, + boolean removedByUser) { + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifLifetimeExtender.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifLifetimeExtender.java new file mode 100644 index 000000000000..2c7b13866c10 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifLifetimeExtender.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.collection; + +import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason; + +/** + * A way for other code to temporarily extend the lifetime of a notification after it has been + * retracted. See {@link NotifCollection#addNotificationLifetimeExtender(NotifLifetimeExtender)}. + */ +public interface NotifLifetimeExtender { + /** Name to associate with this extender (for the purposes of debugging) */ + String getName(); + + /** + * Called on the extender immediately after it has been registered. The extender should hang on + * to this callback and execute it whenever it no longer needs to extend the lifetime of a + * notification. + */ + void setCallback(OnEndLifetimeExtensionCallback callback); + + /** + * Called by the NotifCollection whenever a notification has been retracted (by the app) or + * dismissed (by the user). If the extender returns true, it is considered to be extending the + * lifetime of that notification. Lifetime-extended notifications are kept around until all + * active extenders expire their extension by calling onEndLifetimeExtension(). This method is + * called on all lifetime extenders even if earlier ones return true (in other words, multiple + * lifetime extenders can be extending a notification at the same time). + */ + boolean shouldExtendLifetime(NotificationEntry entry, @CancellationReason int reason); + + /** + * Called by the NotifCollection to inform a lifetime extender that its extension of a notif + * is no longer valid (usually because the notif has been reposted and so no longer needs + * lifetime extension). The extender should clean up any references it has to the notif in + * question. + */ + void cancelLifetimeExtension(NotificationEntry entry); + + /** Callback for notifying the NotifCollection that a lifetime extension has expired. */ + interface OnEndLifetimeExtensionCallback { + void onEndLifetimeExtension(NotifLifetimeExtender extender, NotificationEntry entry); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilder.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilder.java new file mode 100644 index 000000000000..17fef6850f97 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilder.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.collection; + +import java.util.Collection; + +/** + * Interface for the class responsible for converting a NotifCollection into the final sorted, + * filtered, and grouped list of currently visible notifications. + */ +public interface NotifListBuilder { + /** + * Called after the NotifCollection has received an update from NotificationManager but before + * it dispatches any change events to its listeners. This is to inform the list builder that + * the first stage of the pipeline has been triggered. After events have been dispatched, + * onBuildList() will be called. + * + * While onBuildList() is always called after this method is called, the converse is not always + * true: sometimes the NotifCollection applies an update that does not need to dispatch events, + * in which case this method will be skipped and onBuildList will be called directly. + */ + void onBeginDispatchToListeners(); + + /** + * Called by the NotifCollection to indicate that something in the collection has changed and + * that the list builder should regenerate the list. + */ + void onBuildList(Collection<NotificationEntry> entries); +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java index c3211e307845..b12461a36a6a 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java @@ -94,6 +94,20 @@ public final class NotificationEntry { public StatusBarNotification notification; private Ranking mRanking; + + /* + * Bookkeeping members + */ + + /** List of lifetime extenders that are extending the lifetime of this notification. */ + final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>(); + + + /* + * Old members + * TODO: Remove every member beneath this line if possible + */ + public boolean noisy; public StatusBarIconView icon; public StatusBarIconView expandedIcon; diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java index c092f9b0a4c3..8e70d082dbb6 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java @@ -179,6 +179,7 @@ import com.android.systemui.statusbar.BackDropView; import com.android.systemui.statusbar.CommandQueue; import com.android.systemui.statusbar.CrossFadeHelper; import com.android.systemui.statusbar.EmptyShadeView; +import com.android.systemui.statusbar.FeatureFlags; import com.android.systemui.statusbar.GestureRecorder; import com.android.systemui.statusbar.KeyboardShortcuts; import com.android.systemui.statusbar.KeyguardIndicationController; @@ -198,7 +199,7 @@ import com.android.systemui.statusbar.VibratorHelper; import com.android.systemui.statusbar.notification.ActivityLaunchAnimator; import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier; import com.android.systemui.statusbar.notification.DynamicPrivacyController; -import com.android.systemui.statusbar.notification.NotifPipelineInitializer; +import com.android.systemui.statusbar.notification.NewNotifPipeline; import com.android.systemui.statusbar.notification.NotificationActivityStarter; import com.android.systemui.statusbar.notification.NotificationAlertingManager; import com.android.systemui.statusbar.notification.NotificationClicker; @@ -246,6 +247,7 @@ import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; +import dagger.Lazy; import dagger.Subcomponent; @Singleton @@ -370,6 +372,7 @@ public class StatusBar extends SystemUI implements DemoMode, private final Object mQueueLock = new Object(); + private final FeatureFlags mFeatureFlags; private final StatusBarIconController mIconController; private final DozeLog mDozeLog; private final InjectionInflationController mInjectionInflater; @@ -381,7 +384,7 @@ public class StatusBar extends SystemUI implements DemoMode, private final DynamicPrivacyController mDynamicPrivacyController; private final BypassHeadsUpNotifier mBypassHeadsUpNotifier; private final boolean mAllowNotificationLongPress; - private final NotifPipelineInitializer mNotifPipelineInitializer; + private final Lazy<NewNotifPipeline> mNewNotifPipeline; private final FalsingManager mFalsingManager; private final BroadcastDispatcher mBroadcastDispatcher; private final ConfigurationController mConfigurationController; @@ -621,6 +624,7 @@ public class StatusBar extends SystemUI implements DemoMode, @Inject public StatusBar( Context context, + FeatureFlags featureFlags, LightBarController lightBarController, AutoHideController autoHideController, KeyguardUpdateMonitor keyguardUpdateMonitor, @@ -635,7 +639,7 @@ public class StatusBar extends SystemUI implements DemoMode, DynamicPrivacyController dynamicPrivacyController, BypassHeadsUpNotifier bypassHeadsUpNotifier, @Named(ALLOW_NOTIFICATION_LONG_PRESS_NAME) boolean allowNotificationLongPress, - NotifPipelineInitializer notifPipelineInitializer, + Lazy<NewNotifPipeline> newNotifPipeline, FalsingManager falsingManager, BroadcastDispatcher broadcastDispatcher, RemoteInputQuickSettingsDisabler remoteInputQuickSettingsDisabler, @@ -677,6 +681,7 @@ public class StatusBar extends SystemUI implements DemoMode, NotifLog notifLog, DozeParameters dozeParameters) { super(context); + mFeatureFlags = featureFlags; mLightBarController = lightBarController; mAutoHideController = autoHideController; mKeyguardUpdateMonitor = keyguardUpdateMonitor; @@ -691,7 +696,7 @@ public class StatusBar extends SystemUI implements DemoMode, mDynamicPrivacyController = dynamicPrivacyController; mBypassHeadsUpNotifier = bypassHeadsUpNotifier; mAllowNotificationLongPress = allowNotificationLongPress; - mNotifPipelineInitializer = notifPipelineInitializer; + mNewNotifPipeline = newNotifPipeline; mFalsingManager = falsingManager; mBroadcastDispatcher = broadcastDispatcher; mRemoteInputQuickSettingsDisabler = remoteInputQuickSettingsDisabler; @@ -1211,7 +1216,9 @@ public class StatusBar extends SystemUI implements DemoMode, mGroupAlertTransferHelper.bind(mEntryManager, mGroupManager); mNotificationListController.bind(); - mNotifPipelineInitializer.initialize(mNotificationListener); + if (mFeatureFlags.isNewNotifPipelineEnabled()) { + mNewNotifPipeline.get().initialize(mNotificationListener); + } } /** diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java new file mode 100644 index 000000000000..e4a67dbbfa20 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java @@ -0,0 +1,625 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.statusbar.notification.collection; + +import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL; +import static android.service.notification.NotificationListenerService.REASON_CLICK; + +import static com.android.internal.util.Preconditions.checkNotNull; +import static com.android.systemui.statusbar.notification.collection.NotifCollection.REASON_UNKNOWN; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import android.annotation.Nullable; +import android.os.RemoteException; +import android.service.notification.NotificationListenerService.Ranking; +import android.service.notification.NotificationListenerService.RankingMap; +import android.service.notification.NotificationStats; +import android.service.notification.StatusBarNotification; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.util.ArrayMap; + +import androidx.test.filters.SmallTest; + +import com.android.internal.statusbar.IStatusBarService; +import com.android.internal.statusbar.NotificationVisibility; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.statusbar.NotificationEntryBuilder; +import com.android.systemui.statusbar.NotificationListener; +import com.android.systemui.statusbar.NotificationListener.NotifServiceListener; +import com.android.systemui.statusbar.RankingBuilder; +import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason; +import com.android.systemui.util.Assert; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; + +import java.util.Arrays; +import java.util.Map; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper +public class NotifCollectionTest extends SysuiTestCase { + + @Mock private IStatusBarService mStatusBarService; + @Mock private NotificationListener mListenerService; + @Spy private RecordingCollectionListener mCollectionListener; + + @Spy private RecordingLifetimeExtender mExtender1 = new RecordingLifetimeExtender("Extender1"); + @Spy private RecordingLifetimeExtender mExtender2 = new RecordingLifetimeExtender("Extender2"); + @Spy private RecordingLifetimeExtender mExtender3 = new RecordingLifetimeExtender("Extender3"); + + @Captor private ArgumentCaptor<NotifServiceListener> mListenerCaptor; + @Captor private ArgumentCaptor<NotificationEntry> mEntryCaptor; + + private NotifCollection mCollection; + private NotifServiceListener mServiceListener; + + private NoManSimulator mNoMan; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + Assert.sMainLooper = TestableLooper.get(this).getLooper(); + + mCollection = new NotifCollection(mStatusBarService); + mCollection.attach(mListenerService); + mCollection.addCollectionListener(mCollectionListener); + + // Capture the listener object that the collection registers with the listener service so + // we can simulate listener service events in tests below + verify(mListenerService).setDownstreamListener(mListenerCaptor.capture()); + mServiceListener = checkNotNull(mListenerCaptor.getValue()); + + mNoMan = new NoManSimulator(mServiceListener); + } + + @Test + public void testEventDispatchedWhenNotifPosted() { + // WHEN a notification is posted + PostedNotif notif1 = mNoMan.postNotif( + buildNotif(TEST_PACKAGE, 3) + .setRank(4747)); + + // THEN the listener is notified + verify(mCollectionListener).onEntryAdded(mEntryCaptor.capture()); + + NotificationEntry entry = mEntryCaptor.getValue(); + assertEquals(notif1.key, entry.key()); + assertEquals(notif1.sbn, entry.sbn()); + assertEquals(notif1.ranking, entry.ranking()); + } + + @Test + public void testEventDispatchedWhenNotifUpdated() { + // GIVEN a collection with one notif + mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3) + .setRank(4747)); + + // WHEN the notif is reposted + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3) + .setRank(89)); + + // THEN the listener is notified + verify(mCollectionListener).onEntryUpdated(mEntryCaptor.capture()); + + NotificationEntry entry = mEntryCaptor.getValue(); + assertEquals(notif2.key, entry.key()); + assertEquals(notif2.sbn, entry.sbn()); + assertEquals(notif2.ranking, entry.ranking()); + } + + @Test + public void testEventDispatchedWhenNotifRemoved() { + // GIVEN a collection with one notif + mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3)); + clearInvocations(mCollectionListener); + + PostedNotif notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + NotificationEntry entry = mCollectionListener.getEntry(notif.key); + clearInvocations(mCollectionListener); + + // WHEN a notif is retracted + mNoMan.retractNotif(notif.sbn, REASON_APP_CANCEL); + + // THEN the listener is notified + verify(mCollectionListener).onEntryRemoved(entry, REASON_APP_CANCEL, false); + assertEquals(notif.sbn, entry.sbn()); + assertEquals(notif.ranking, entry.ranking()); + } + + @Test + public void testRankingsAreUpdatedForOtherNotifs() { + // GIVEN a collection with one notif + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3) + .setRank(47)); + NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key); + + // WHEN a new notif is posted, triggering a rerank + mNoMan.setRanking(notif1.sbn.getKey(), new RankingBuilder(notif1.ranking) + .setRank(56) + .build()); + mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 77)); + + // THEN the ranking is updated on the first entry + assertEquals(56, entry1.ranking().getRank()); + } + + @Test + public void testRankingUpdateIsProperlyIssuedToEveryone() { + // GIVEN a collection with a couple notifs + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3) + .setRank(3)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 8) + .setRank(2)); + PostedNotif notif3 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 77) + .setRank(1)); + + NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + NotificationEntry entry3 = mCollectionListener.getEntry(notif3.key); + + // WHEN a ranking update is delivered + Ranking newRanking1 = new RankingBuilder(notif1.ranking) + .setRank(4) + .setExplanation("Foo bar") + .build(); + Ranking newRanking2 = new RankingBuilder(notif2.ranking) + .setRank(5) + .setExplanation("baz buzz") + .build(); + Ranking newRanking3 = new RankingBuilder(notif3.ranking) + .setRank(6) + .setExplanation("Penguin pizza") + .build(); + + mNoMan.setRanking(notif1.sbn.getKey(), newRanking1); + mNoMan.setRanking(notif2.sbn.getKey(), newRanking2); + mNoMan.setRanking(notif3.sbn.getKey(), newRanking3); + mNoMan.issueRankingUpdate(); + + // THEN all of the NotifEntries have their rankings properly updated + assertEquals(newRanking1, entry1.ranking()); + assertEquals(newRanking2, entry2.ranking()); + assertEquals(newRanking3, entry3.ranking()); + } + + @Test + public void testNotifEntriesAreNotPersistedAcrossRemovalAndReposting() { + // GIVEN a notification that has been posted + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3)); + NotificationEntry entry1 = mCollectionListener.getEntry(notif1.key); + + // WHEN the notification is retracted and then reposted + mNoMan.retractNotif(notif1.sbn, REASON_APP_CANCEL); + mNoMan.postNotif(buildNotif(TEST_PACKAGE, 3)); + + // THEN the new NotificationEntry is a new object + NotificationEntry entry2 = mCollectionListener.getEntry(notif1.key); + assertNotEquals(entry2, entry1); + } + + @Test + public void testDismissNotification() throws RemoteException { + // GIVEN a collection with a couple notifications and a lifetime extender + mCollection.addNotificationLifetimeExtender(mExtender1); + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag")); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88, "barTag")); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // WHEN a notification is manually dismissed + DismissedByUserStats stats = new DismissedByUserStats( + NotificationStats.DISMISSAL_SHADE, + NotificationStats.DISMISS_SENTIMENT_NEUTRAL, + NotificationVisibility.obtain(entry2.key(), 7, 2, true)); + + mCollection.dismissNotification(entry2, REASON_CLICK, stats); + + // THEN we check for lifetime extension + verify(mExtender1).shouldExtendLifetime(entry2, REASON_CLICK); + + // THEN we send the dismissal to system server + verify(mStatusBarService).onNotificationClear( + notif2.sbn.getPackageName(), + notif2.sbn.getTag(), + 88, + notif2.sbn.getUser().getIdentifier(), + notif2.sbn.getKey(), + stats.dismissalSurface, + stats.dismissalSentiment, + stats.notificationVisibility); + + // THEN we fire a remove event + verify(mCollectionListener).onEntryRemoved(entry2, REASON_CLICK, true); + } + + @Test(expected = IllegalStateException.class) + public void testDismissingNonExistentNotificationThrows() { + // GIVEN a collection that originally had three notifs, but where one was dismissed + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + PostedNotif notif3 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 99)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN); + + // WHEN we try to dismiss a notification that isn't present + mCollection.dismissNotification( + entry2, + REASON_CLICK, + new DismissedByUserStats(0, 0, NotificationVisibility.obtain("foo", 47, 3, true))); + + // THEN an exception is thrown + } + + @Test + public void testLifetimeExtendersAreQueriedWhenNotifRemoved() { + // GIVEN a couple notifications and a few lifetime extenders + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = true; + + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // WHEN a notification is removed + mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN); + + // THEN each extender is asked whether to extend, even if earlier ones return true + verify(mExtender1).shouldExtendLifetime(entry2, REASON_UNKNOWN); + verify(mExtender2).shouldExtendLifetime(entry2, REASON_UNKNOWN); + verify(mExtender3).shouldExtendLifetime(entry2, REASON_UNKNOWN); + + // THEN the entry is not removed + assertTrue(mCollection.getNotifs().contains(entry2)); + + // THEN the entry properly records all extenders that returned true + assertEquals(Arrays.asList(mExtender1, mExtender2), entry2.mLifetimeExtenders); + } + + @Test + public void testWhenLastLifetimeExtenderExpiresAllAreReQueried() { + // GIVEN a couple notifications and a few lifetime extenders + mExtender2.shouldExtendLifetime = true; + + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // GIVEN a notification gets lifetime-extended by one of them + mNoMan.retractNotif(notif2.sbn, REASON_APP_CANCEL); + assertTrue(mCollection.getNotifs().contains(entry2)); + clearInvocations(mExtender1, mExtender2, mExtender3); + + // WHEN the last active extender expires (but new ones become active) + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = false; + mExtender3.shouldExtendLifetime = true; + mExtender2.callback.onEndLifetimeExtension(mExtender2, entry2); + + // THEN each extender is re-queried + verify(mExtender1).shouldExtendLifetime(entry2, REASON_UNKNOWN); + verify(mExtender2).shouldExtendLifetime(entry2, REASON_UNKNOWN); + verify(mExtender3).shouldExtendLifetime(entry2, REASON_UNKNOWN); + + // THEN the entry is not removed + assertTrue(mCollection.getNotifs().contains(entry2)); + + // THEN the entry properly records all extenders that returned true + assertEquals(Arrays.asList(mExtender1, mExtender3), entry2.mLifetimeExtenders); + } + + @Test + public void testExtendersAreNotReQueriedUntilFinalActiveExtenderExpires() { + // GIVEN a couple notifications and a few lifetime extenders + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = true; + + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // GIVEN a notification gets lifetime-extended by a couple of them + mNoMan.retractNotif(notif2.sbn, REASON_APP_CANCEL); + assertTrue(mCollection.getNotifs().contains(entry2)); + clearInvocations(mExtender1, mExtender2, mExtender3); + + // WHEN one (but not all) of the extenders expires + mExtender2.shouldExtendLifetime = false; + mExtender2.callback.onEndLifetimeExtension(mExtender2, entry2); + + // THEN the entry is not removed + assertTrue(mCollection.getNotifs().contains(entry2)); + + // THEN we don't re-query the extenders + verify(mExtender1, never()).shouldExtendLifetime(eq(entry2), anyInt()); + verify(mExtender2, never()).shouldExtendLifetime(eq(entry2), anyInt()); + verify(mExtender3, never()).shouldExtendLifetime(eq(entry2), anyInt()); + + // THEN the entry properly records all extenders that returned true + assertEquals(Arrays.asList(mExtender1), entry2.mLifetimeExtenders); + } + + @Test + public void testNotificationIsRemovedWhenAllLifetimeExtendersExpire() { + // GIVEN a couple notifications and a few lifetime extenders + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = true; + + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // GIVEN a notification gets lifetime-extended by a couple of them + mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN); + assertTrue(mCollection.getNotifs().contains(entry2)); + clearInvocations(mExtender1, mExtender2, mExtender3); + + // WHEN all of the active extenders expire + mExtender2.shouldExtendLifetime = false; + mExtender2.callback.onEndLifetimeExtension(mExtender2, entry2); + mExtender1.shouldExtendLifetime = false; + mExtender1.callback.onEndLifetimeExtension(mExtender1, entry2); + + // THEN the entry removed + assertFalse(mCollection.getNotifs().contains(entry2)); + verify(mCollectionListener).onEntryRemoved(entry2, REASON_UNKNOWN, false); + } + + @Test + public void testLifetimeExtensionIsCanceledWhenNotifIsUpdated() { + // GIVEN a few lifetime extenders and a couple notifications + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = true; + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // GIVEN a notification gets lifetime-extended by a couple of them + mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN); + assertTrue(mCollection.getNotifs().contains(entry2)); + clearInvocations(mExtender1, mExtender2, mExtender3); + + // WHEN the notification is reposted + mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + + // THEN all of the active lifetime extenders are canceled + verify(mExtender1).cancelLifetimeExtension(entry2); + verify(mExtender2).cancelLifetimeExtension(entry2); + + // THEN the notification is still present + assertTrue(mCollection.getNotifs().contains(entry2)); + } + + @Test(expected = IllegalStateException.class) + public void testReentrantCallsToLifetimeExtendersThrow() { + // GIVEN a few lifetime extenders and a couple notifications + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = true; + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // GIVEN a notification gets lifetime-extended by a couple of them + mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN); + assertTrue(mCollection.getNotifs().contains(entry2)); + clearInvocations(mExtender1, mExtender2, mExtender3); + + // WHEN a lifetime extender makes a reentrant call during cancelLifetimeExtension() + mExtender2.onCancelLifetimeExtension = () -> { + mExtender2.callback.onEndLifetimeExtension(mExtender2, entry2); + }; + // This triggers the call to cancelLifetimeExtension() + mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + + // THEN an exception is thrown + } + + @Test + public void testRankingIsUpdatedWhenALifetimeExtendedNotifIsReposted() { + // GIVEN a few lifetime extenders and a couple notifications + mCollection.addNotificationLifetimeExtender(mExtender1); + mCollection.addNotificationLifetimeExtender(mExtender2); + mCollection.addNotificationLifetimeExtender(mExtender3); + + mExtender1.shouldExtendLifetime = true; + mExtender2.shouldExtendLifetime = true; + + PostedNotif notif1 = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47)); + PostedNotif notif2 = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88)); + NotificationEntry entry2 = mCollectionListener.getEntry(notif2.key); + + // GIVEN a notification gets lifetime-extended by a couple of them + mNoMan.retractNotif(notif2.sbn, REASON_UNKNOWN); + assertTrue(mCollection.getNotifs().contains(entry2)); + clearInvocations(mExtender1, mExtender2, mExtender3); + + // WHEN the notification is reposted + PostedNotif notif2a = mNoMan.postNotif(buildNotif(TEST_PACKAGE2, 88) + .setRank(4747) + .setExplanation("Some new explanation")); + + // THEN the notification's ranking is properly updated + assertEquals(notif2a.ranking, entry2.ranking()); + } + + private static NotificationEntryBuilder buildNotif(String pkg, int id, String tag) { + return new NotificationEntryBuilder() + .setPkg(pkg) + .setId(id) + .setTag(tag); + } + + private static NotificationEntryBuilder buildNotif(String pkg, int id) { + return new NotificationEntryBuilder() + .setPkg(pkg) + .setId(id); + } + + private static class NoManSimulator { + private final NotifServiceListener mListener; + private final Map<String, Ranking> mRankings = new ArrayMap<>(); + + private NoManSimulator( + NotifServiceListener listener) { + mListener = listener; + } + + PostedNotif postNotif(NotificationEntryBuilder builder) { + NotificationEntry entry = builder.build(); + mRankings.put(entry.key(), entry.ranking()); + mListener.onNotificationPosted(entry.sbn(), buildRankingMap()); + return new PostedNotif(entry.sbn(), entry.ranking()); + } + + void retractNotif(StatusBarNotification sbn, int reason) { + assertNotNull(mRankings.remove(sbn.getKey())); + mListener.onNotificationRemoved(sbn, buildRankingMap(), reason); + } + + void issueRankingUpdate() { + mListener.onNotificationRankingUpdate(buildRankingMap()); + } + + void setRanking(String key, Ranking ranking) { + mRankings.put(key, ranking); + } + + private RankingMap buildRankingMap() { + return new RankingMap(mRankings.values().toArray(new Ranking[0])); + } + } + + private static class PostedNotif { + public final String key; + public final StatusBarNotification sbn; + public final Ranking ranking; + + private PostedNotif(StatusBarNotification sbn, + Ranking ranking) { + this.key = sbn.getKey(); + this.sbn = sbn; + this.ranking = ranking; + } + } + + private static class RecordingCollectionListener implements NotifCollectionListener { + private final Map<String, NotificationEntry> mLastSeenEntries = new ArrayMap<>(); + + @Override + public void onEntryAdded(NotificationEntry entry) { + mLastSeenEntries.put(entry.key(), entry); + } + + @Override + public void onEntryUpdated(NotificationEntry entry) { + } + + @Override + public void onEntryRemoved(NotificationEntry entry, int reason, boolean removedByUser) { + } + + public NotificationEntry getEntry(String key) { + if (!mLastSeenEntries.containsKey(key)) { + throw new RuntimeException("Key not found: " + key); + } + return mLastSeenEntries.get(key); + } + } + + private static class RecordingLifetimeExtender implements NotifLifetimeExtender { + private final String mName; + + public @Nullable OnEndLifetimeExtensionCallback callback; + public boolean shouldExtendLifetime = false; + public @Nullable Runnable onCancelLifetimeExtension; + + private RecordingLifetimeExtender(String name) { + mName = name; + } + + @Override + public String getName() { + return mName; + } + + @Override + public void setCallback(OnEndLifetimeExtensionCallback callback) { + this.callback = callback; + } + + @Override + public boolean shouldExtendLifetime( + NotificationEntry entry, + @CancellationReason int reason) { + return shouldExtendLifetime; + } + + @Override + public void cancelLifetimeExtension(NotificationEntry entry) { + if (onCancelLifetimeExtension != null) { + onCancelLifetimeExtension.run(); + } + } + } + + private static final String TEST_PACKAGE = "com.android.test.collection"; + private static final String TEST_PACKAGE2 = "com.android.test.collection2"; +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java index 8f1b6017aff9..52b3720125c8 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java @@ -91,6 +91,7 @@ import com.android.systemui.keyguard.WakefulnessLifecycle; import com.android.systemui.plugins.ActivityStarter.OnDismissAction; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.statusbar.CommandQueue; +import com.android.systemui.statusbar.FeatureFlags; import com.android.systemui.statusbar.KeyguardIndicationController; import com.android.systemui.statusbar.NavigationBarController; import com.android.systemui.statusbar.NotificationEntryBuilder; @@ -107,7 +108,7 @@ import com.android.systemui.statusbar.StatusBarStateControllerImpl; import com.android.systemui.statusbar.VibratorHelper; import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier; import com.android.systemui.statusbar.notification.DynamicPrivacyController; -import com.android.systemui.statusbar.notification.NotifPipelineInitializer; +import com.android.systemui.statusbar.notification.NewNotifPipeline; import com.android.systemui.statusbar.notification.NotificationAlertingManager; import com.android.systemui.statusbar.notification.NotificationEntryListener; import com.android.systemui.statusbar.notification.NotificationEntryManager; @@ -156,6 +157,7 @@ public class StatusBarTest extends SysuiTestCase { private TestableNotificationInterruptionStateProvider mNotificationInterruptionStateProvider; private CommandQueue mCommandQueue; + @Mock private FeatureFlags mFeatureFlags; @Mock private LightBarController mLightBarController; @Mock private StatusBarIconController mStatusBarIconController; @Mock private StatusBarKeyguardViewManager mStatusBarKeyguardViewManager; @@ -205,7 +207,7 @@ public class StatusBarTest extends SysuiTestCase { @Mock private KeyguardBypassController mKeyguardBypassController; @Mock private InjectionInflationController mInjectionInflationController; @Mock private DynamicPrivacyController mDynamicPrivacyController; - @Mock private NotifPipelineInitializer mNotifPipelineInitializer; + @Mock private NewNotifPipeline mNewNotifPipeline; @Mock private ZenModeController mZenModeController; @Mock private AutoHideController mAutoHideController; @Mock private NotificationViewHierarchyManager mNotificationViewHierarchyManager; @@ -288,6 +290,7 @@ public class StatusBarTest extends SysuiTestCase { mStatusBar = new StatusBar( mContext, + mFeatureFlags, mLightBarController, mAutoHideController, mKeyguardUpdateMonitor, @@ -302,7 +305,7 @@ public class StatusBarTest extends SysuiTestCase { mDynamicPrivacyController, mBypassHeadsUpNotifier, true, - mNotifPipelineInitializer, + () -> mNewNotifPipeline, new FalsingManagerFake(), mBroadcastDispatcher, new RemoteInputQuickSettingsDisabler( |