Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 3b8c4743 authored by Evan Laird's avatar Evan Laird
Browse files

Force FGS notifications to show for a minimum time

It's possible for a service to do a start/stop foreground and cause a
couple of things to happen:

NotificationManagerService will enqueue a EnqueueNotificationRunnable,
post a PostNotificationRunnable (for the startForeground), and then also
enqueue a CancelNotificationRunnable. There is some racy behavior here
in that the cancel runnable can get triggered in between enqueue and
post runnables. If the cancel happens first, then
NotificationListenerServices will never get the message.

This behavior is technically allowed, however for foreground services we
want to ensure that there is a minmum amount of time that notification
listeners are aware of the foreground service so that (for instance) the
FGS notification can be shown.

This CL does two things to mitigate this problem:

1. Introduce checking in the CancelNotificationRunnable such that it
will not cancel until after PostNotificationRunnable has finished
executing.

2. Introduce a NotificationLifetimeExtender method that will allow a
lifetime extender to manage the lifetime of a notification that has been
enqueued but not inflated yet.

Bug: 119041698
Test: atest NotificationManagerServiceTest
Test: atest ForegroundServiceLifetimeExtenderTest
Change-Id: I0680034ed9315aa2c05282524d48faaed066ebd0
Merged-In: I0680034ed9315aa2c05282524d48faaed066ebd0
parent 86ddd9eb
Loading
Loading
Loading
Loading
+89 −0
Original line number Diff line number Diff line
/*
 * 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);
    }
}
+64 −1
Original line number Diff line number Diff line
@@ -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;
@@ -238,6 +246,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: ");
@@ -252,6 +270,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,
@@ -300,6 +319,12 @@ public class NotificationEntryManager implements Dumpable, NotificationInflater.
        mOnAppOpsClickListener = mGutsManager::openGuts;
    }

    @VisibleForTesting
    protected Map<NotificationData.Entry, NotificationLifetimeExtender>
    getRetainedNotificationMap() {
        return mRetainedNotifications;
    }

    public NotificationData getNotificationData() {
        return mNotificationData;
    }
@@ -485,8 +510,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.
@@ -545,6 +579,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);

@@ -580,9 +619,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;
@@ -872,6 +932,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);

+65 −0
Original line number Diff line number Diff line
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);
    }
}
+88 −0
Original line number Diff line number Diff line
/*
 * 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
+48 −0
Original line number Diff line number Diff line
@@ -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));
    }
}
Loading