diff options
7 files changed, 377 insertions, 3 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/ForegroundServiceLifetimeExtender.java b/packages/SystemUI/src/com/android/systemui/statusbar/ForegroundServiceLifetimeExtender.java new file mode 100644 index 000000000000..c1a06a2efc49 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/ForegroundServiceLifetimeExtender.java @@ -0,0 +1,89 @@ +/* + * 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.app.Notification; +import android.os.Handler; +import android.os.Looper; +import android.util.ArraySet; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.systemui.statusbar.NotificationData; + +/** + * Extends the lifetime of foreground notification services such that they show for at least + * five seconds + */ +public class ForegroundServiceLifetimeExtender implements NotificationLifetimeExtender { + + private static final String TAG = "FGSLifetimeExtender"; + @VisibleForTesting + static final int MIN_FGS_TIME_MS = 5000; + + private NotificationSafeToRemoveCallback mNotificationSafeToRemoveCallback; + private ArraySet<NotificationData.Entry> mManagedEntries = new ArraySet<>(); + private Handler mHandler = new Handler(Looper.getMainLooper()); + + ForegroundServiceLifetimeExtender() { + } + + @Override + public void setCallback(@NonNull NotificationSafeToRemoveCallback callback) { + mNotificationSafeToRemoveCallback = callback; + } + + @Override + public boolean shouldExtendLifetime(@NonNull NotificationData.Entry entry) { + if ((entry.notification.getNotification().flags + & Notification.FLAG_FOREGROUND_SERVICE) == 0) { + return false; + } + + long currentTime = System.currentTimeMillis(); + return currentTime - entry.notification.getPostTime() < MIN_FGS_TIME_MS; + } + + @Override + public boolean shouldExtendLifetimeForPendingNotification( + @NonNull NotificationData.Entry entry) { + return shouldExtendLifetime(entry); + } + + @Override + public void setShouldManageLifetime( + @NonNull NotificationData.Entry entry, boolean shouldManage) { + if (!shouldManage) { + mManagedEntries.remove(entry); + return; + } + + mManagedEntries.add(entry); + + Runnable r = () -> { + if (mManagedEntries.contains(entry)) { + mManagedEntries.remove(entry); + if (mNotificationSafeToRemoveCallback != null) { + mNotificationSafeToRemoveCallback.onSafeToRemove(entry.key); + } + } + }; + long delayAmt = MIN_FGS_TIME_MS + - (System.currentTimeMillis() - entry.notification.getPostTime()); + mHandler.postDelayed(r, delayAmt); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationEntryManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationEntryManager.java index 06f26c9cbc7c..148be512c5b7 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationEntryManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationEntryManager.java @@ -38,6 +38,7 @@ import android.service.notification.NotificationStats; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.util.ArraySet; +import android.util.ArrayMap; import android.util.EventLog; import android.util.Log; import android.view.View; @@ -71,6 +72,7 @@ import java.io.PrintWriter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; /** * NotificationEntryManager is responsible for the adding, removing, and updating of notifications. @@ -116,6 +118,12 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. private final SmartReplyController mSmartReplyController = Dependency.get(SmartReplyController.class); + // A lifetime extender that watches for foreground service notifications + private final NotificationLifetimeExtender mFGSExtender = + new ForegroundServiceLifetimeExtender(); + private final Map<NotificationData.Entry, NotificationLifetimeExtender> mRetainedNotifications = + new ArrayMap<>(); + protected IStatusBarService mBarService; protected NotificationPresenter mPresenter; protected Callback mCallback; @@ -227,6 +235,16 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. pw.println(entry.notification); } } + pw.println(" Lifetime-extended notifications:"); + if (mRetainedNotifications.isEmpty()) { + pw.println(" None"); + } else { + for (Map.Entry<NotificationData.Entry, NotificationLifetimeExtender> entry + : mRetainedNotifications.entrySet()) { + pw.println(" " + entry.getKey().notification + " retained by " + + entry.getValue().getClass().getName()); + } + } pw.print(" mUseHeadsUp="); pw.println(mUseHeadsUp); pw.print(" mKeysKeptForRemoteInput: "); @@ -241,6 +259,7 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. mMessagingUtil = new NotificationMessagingUtil(context); mSystemServicesProxy = SystemServicesProxy.getInstance(mContext); mGroupManager.setPendingEntries(mPendingNotifications); + mFGSExtender.setCallback(key -> removeNotification(key, mLatestRankingMap)); } public void setUpWithPresenter(NotificationPresenter presenter, @@ -289,6 +308,12 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. mOnAppOpsClickListener = mGutsManager::openGuts; } + @VisibleForTesting + protected Map<NotificationData.Entry, NotificationLifetimeExtender> + getRetainedNotificationMap() { + return mRetainedNotifications; + } + public NotificationData getNotificationData() { return mNotificationData; } @@ -474,8 +499,17 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. @Override public void removeNotification(String key, NotificationListenerService.RankingMap ranking) { - boolean deferRemoval = false; + // First chance to extend the lifetime of a notification + NotificationData.Entry pendingEntry = mPendingNotifications.get(key); + if (pendingEntry != null) { + if (mFGSExtender.shouldExtendLifetimeForPendingNotification(pendingEntry)) { + extendLifetime(pendingEntry, mFGSExtender); + return; + } + } + abortExistingInflation(key); + boolean deferRemoval = false; if (mHeadsUpManager.isHeadsUp(key)) { // A cancel() in response to a remote input shouldn't be delayed, as it makes the // sending look longer than it takes. @@ -534,6 +568,11 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. } } + if (entry != null && mFGSExtender.shouldExtendLifetime(entry)) { + extendLifetime(entry, mFGSExtender); + return; + } + // Actually removing notification so smart reply controller can forget about it. mSmartReplyController.stopSending(entry); @@ -569,9 +608,30 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. handleGroupSummaryRemoved(key); StatusBarNotification old = removeNotificationViews(key, ranking); + // Make sure no lifetime extension is happening anymore + cancelLifetimeExtension(entry); mCallback.onNotificationRemoved(key, old); } + private void extendLifetime( + NotificationData.Entry entry, NotificationLifetimeExtender extender) { + // Cancel any other extender which might be holding on to this notification entry + NotificationLifetimeExtender activeExtender = mRetainedNotifications.get(entry); + if (activeExtender != null && activeExtender != extender) { + activeExtender.setShouldManageLifetime(entry, false); + } + + mRetainedNotifications.put(entry, extender); + extender.setShouldManageLifetime(entry, true); + } + + private void cancelLifetimeExtension(NotificationData.Entry entry) { + NotificationLifetimeExtender activeExtender = mRetainedNotifications.remove(entry); + if (activeExtender != null) { + activeExtender.setShouldManageLifetime(entry, false); + } + } + public StatusBarNotification rebuildNotificationWithRemoteInput(NotificationData.Entry entry, CharSequence remoteInputText, boolean showSpinner) { StatusBarNotification sbn = entry.notification; @@ -855,6 +915,9 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater. Log.w(TAG, "Notification that was kept for guts was updated. " + key); } + // No need to keep the lifetime extension around if an update comes in + cancelLifetimeExtension(entry); + Notification n = notification.getNotification(); mNotificationData.updateRanking(ranking); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLifetimeExtender.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLifetimeExtender.java new file mode 100644 index 000000000000..96cfab7aeec9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLifetimeExtender.java @@ -0,0 +1,65 @@ +package com.android.systemui.statusbar; + +import android.annotation.NonNull; + +import com.android.systemui.statusbar.NotificationData; + +/** + * Interface for anything that may need to keep notifications managed even after + * {@link NotificationListener} removes it. The lifetime extender is in charge of performing the + * callback when the notification is then safe to remove. + */ +public interface NotificationLifetimeExtender { + + /** + * Set the handler to callback to when the notification is safe to remove. + * + * @param callback the handler to callback + */ + void setCallback(@NonNull NotificationSafeToRemoveCallback callback); + + /** + * Determines whether or not the extender needs the notification kept after removal. + * + * @param entry the entry containing the notification to check + * @return true if the notification lifetime should be extended + */ + boolean shouldExtendLifetime(@NonNull NotificationData.Entry entry); + + /** + * It's possible that a notification was canceled before it ever became visible. This callback + * gives lifetime extenders a chance to make sure it shows up. For example if a foreground + * service is canceled too quickly but we still want to make sure a FGS notification shows. + * @param pendingEntry the canceled (but pending) entry + * @return true if the notification lifetime should be extended + */ + default boolean shouldExtendLifetimeForPendingNotification( + @NonNull NotificationData.Entry pendingEntry) { + return false; + } + + /** + * Sets whether or not the lifetime should be managed by the extender. In practice, if + * shouldManage is true, this is where the extender starts managing the entry internally and is + * now responsible for calling {@link NotificationSafeToRemoveCallback#onSafeToRemove(String)} + * when the entry is safe to remove. If shouldManage is false, the extender no longer needs to + * worry about it (either because we will be removing it anyway or the entry is no longer + * removed due to an update). + * + * @param entry the entry that needs an extended lifetime + * @param shouldManage true if the extender should manage the entry now, false otherwise + */ + void setShouldManageLifetime(@NonNull NotificationData.Entry entry, boolean shouldManage); + + /** + * The callback for when the notification is now safe to remove (i.e. its lifetime has ended). + */ + interface NotificationSafeToRemoveCallback { + /** + * Called when the lifetime extender determines it's safe to remove. + * + * @param key key of the entry that is now safe to remove + */ + void onSafeToRemove(String key); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/ForegroundServiceLifetimeExtenderTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/ForegroundServiceLifetimeExtenderTest.java new file mode 100644 index 000000000000..daf2f95b1f98 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/ForegroundServiceLifetimeExtenderTest.java @@ -0,0 +1,88 @@ +/* + * 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 static com.android.systemui.statusbar.ForegroundServiceLifetimeExtender.MIN_FGS_TIME_MS; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.Notification; +import android.service.notification.NotificationListenerService.Ranking; +import android.service.notification.StatusBarNotification; + +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import com.android.systemui.R; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.statusbar.NotificationData.Entry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ForegroundServiceLifetimeExtenderTest extends SysuiTestCase { + private ForegroundServiceLifetimeExtender mExtender = new ForegroundServiceLifetimeExtender(); + private StatusBarNotification mSbn; + private NotificationData.Entry mEntry; + private Notification mNotif; + + @Before + public void setup() { + mNotif = new Notification.Builder(mContext, "") + .setSmallIcon(R.drawable.ic_person) + .setContentTitle("Title") + .setContentText("Text") + .build(); + + mSbn = mock(StatusBarNotification.class); + when(mSbn.getNotification()).thenReturn(mNotif); + + mEntry = new NotificationData.Entry(mSbn); + } + + /** + * ForegroundServiceLifetimeExtenderTest + */ + @Test + public void testShouldExtendLifetime_should_foreground() { + // Extend the lifetime of a FGS notification iff it has not been visible + // for the minimum time + mNotif.flags |= Notification.FLAG_FOREGROUND_SERVICE; + when(mSbn.getPostTime()).thenReturn(System.currentTimeMillis()); + assertTrue(mExtender.shouldExtendLifetime(mEntry)); + } + + @Test + public void testShouldExtendLifetime_shouldNot_foreground() { + mNotif.flags |= Notification.FLAG_FOREGROUND_SERVICE; + when(mSbn.getPostTime()).thenReturn(System.currentTimeMillis() - MIN_FGS_TIME_MS - 1); + assertFalse(mExtender.shouldExtendLifetime(mEntry)); + } + + @Test + public void testShouldExtendLifetime_shouldNot_notForeground() { + mNotif.flags = 0; + when(mSbn.getPostTime()).thenReturn(System.currentTimeMillis() - MIN_FGS_TIME_MS - 1); + assertFalse(mExtender.shouldExtendLifetime(mEntry)); + } +}
\ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryManagerTest.java index afe16cf13b76..69db4b5fa86d 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryManagerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryManagerTest.java @@ -16,6 +16,8 @@ package com.android.systemui.statusbar; +import static com.android.systemui.statusbar.ForegroundServiceLifetimeExtender.MIN_FGS_TIME_MS; + import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; @@ -54,11 +56,13 @@ import com.android.internal.statusbar.IStatusBarService; import com.android.systemui.ForegroundServiceController; import com.android.systemui.R; import com.android.systemui.SysuiTestCase; +import com.android.systemui.statusbar.NotificationData.Entry; import com.android.systemui.statusbar.notification.VisualStabilityManager; import com.android.systemui.statusbar.phone.NotificationGroupManager; import com.android.systemui.statusbar.policy.DeviceProvisionedController; import com.android.systemui.statusbar.policy.HeadsUpManager; +import java.util.Map; import junit.framework.Assert; import org.junit.Before; @@ -427,4 +431,48 @@ public class NotificationEntryManagerTest extends SysuiTestCase { Assert.assertTrue(newSbn.getNotification().extras .getBoolean(Notification.EXTRA_HIDE_SMART_REPLIES, false)); } + + @Test + public void testForegroundServiceNotificationKeptForFiveSeconds() { + // sbn posted "just now" + Notification n = new Notification.Builder(mContext, "") + .setSmallIcon(R.drawable.ic_person) + .setContentTitle("Title") + .setContentText("Text") + .build(); + n.flags |= Notification.FLAG_FOREGROUND_SERVICE; + mSbn = new StatusBarNotification(TEST_PACKAGE_NAME, TEST_PACKAGE_NAME, 0, null, TEST_UID, + 0, n, new UserHandle(ActivityManager.getCurrentUser()), null, + System.currentTimeMillis()); + mEntry = new Entry(mSbn); + mEntryManager.getNotificationData().add(mEntry); + mEntryManager.removeNotification(mEntry.key, mRankingMap); + + Map<NotificationData.Entry, NotificationLifetimeExtender> map = + mEntryManager.getRetainedNotificationMap(); + + Assert.assertTrue(map.containsKey(mEntry)); + } + + @Test + public void testForegroundServiceNotification_notRetainedIfShownForFiveSeconds() { + // sbn posted "more than 5 seconds ago" + Notification n = new Notification.Builder(mContext, "") + .setSmallIcon(R.drawable.ic_person) + .setContentTitle("Title") + .setContentText("Text") + .build(); + n.flags |= Notification.FLAG_FOREGROUND_SERVICE; + mSbn = new StatusBarNotification(TEST_PACKAGE_NAME, TEST_PACKAGE_NAME, 0, null, TEST_UID, + 0, n, new UserHandle(ActivityManager.getCurrentUser()), null, + System.currentTimeMillis() - MIN_FGS_TIME_MS - 1); + mEntry = new Entry(mSbn); + mEntryManager.getNotificationData().add(mEntry); + mEntryManager.removeNotification(mEntry.key, mRankingMap); + + Map<NotificationData.Entry, NotificationLifetimeExtender> map = + mEntryManager.getRetainedNotificationMap(); + + Assert.assertFalse(map.containsKey(mEntry)); + } } diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java index dfcbf0524c5d..897877ad729f 100644 --- a/services/core/java/com/android/server/notification/NotificationManagerService.java +++ b/services/core/java/com/android/server/notification/NotificationManagerService.java @@ -5658,8 +5658,15 @@ public class NotificationManagerService extends SystemService { userId, mustHaveFlags, mustNotHaveFlags, reason, listenerName); synchronized (mNotificationLock) { - // Look for the notification, searching both the posted and enqueued lists. - NotificationRecord r = findNotificationLocked(pkg, tag, id, userId); + // If the notification is currently enqueued, repost this runnable so it has a + // chance to notify listeners + if ((findNotificationByListLocked( + mEnqueuedNotifications, pkg, tag, id, userId)) != null) { + mHandler.post(this); + } + // Look for the notification in the posted list, since we already checked enq + NotificationRecord r = findNotificationByListLocked( + mNotificationList, pkg, tag, id, userId); if (r != null) { // The notification was found, check if it should be removed. diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java index 3446c16cb32b..8b719de98f04 100644 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java @@ -708,6 +708,20 @@ public class NotificationManagerServiceTest extends UiServiceTestCase { } @Test + public void testCancelImmediatelyAfterEnqueueNotifiesListeners_ForegroundServiceFlag() + throws Exception { + final StatusBarNotification sbn = generateNotificationRecord(null).sbn; + sbn.getNotification().flags = + Notification.FLAG_ONGOING_EVENT | FLAG_FOREGROUND_SERVICE; + mBinderService.enqueueNotificationWithTag(PKG, "opPkg", "tag", + sbn.getId(), sbn.getNotification(), sbn.getUserId()); + mBinderService.cancelNotificationWithTag(PKG, "tag", sbn.getId(), sbn.getUserId()); + waitForIdle(); + verify(mListeners, times(1)).notifyPostedLocked(any(), any()); + verify(mListeners, times(1)).notifyRemovedLocked(any(), anyInt(), any()); + } + + @Test public void testUserInitiatedClearAll_noLeak() throws Exception { final NotificationRecord n = generateNotificationRecord( mTestNotificationChannel, 1, "group", true); |