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

Commit 3a2fcbc6 authored by Lyn's avatar Lyn
Browse files

Batch and throttle appear rate of heads up notifications

New class AvalancheController delays heads up notification
appearance during avalanche by intercepting BaseHeadsUpManager events
that handle HUN drawing and removal scheduling:
- showNotification
- updateNotification
- removeEntry
- unpinAll
- removeAutoRemovalCallbacks
- scheduleAutoRemovalCallback

AvalancheController saves these events as a list of runnables per
HeadsUpEntry that run when the HeadsUpEntry is shown.

BaseHeadsUpManager.HeadsUpEntry
- implements hashCode and equals for key-based lookup
- calculateFinishTime uses 1s removal timeout during avalanche

HeadsUpManagerPhone injects AvalancheController into BaseHeadsUpManager.

Bug: 315362456
Test: atest SystemUiRoboTests:AvalancheControllerTest
Test: adb shell device_config override systemui com.android.systemui.notification_throttle_hun true
      send multiple HUNs in fast succession
      => HUNs appear 1s after each other in batches
      => Only top-priority HUN per batch is shown

Flag: ACONFIG notification_throttle_hun DEVELOPMENT
Change-Id: I7d97e137609fdee854e5b72013759773070777c8
parent c5c5bc55
Loading
Loading
Loading
Loading
+241 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.policy

import android.app.Notification
import android.platform.test.annotations.EnableFlags
import android.testing.TestableLooper.RunWithLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.testing.UiEventLoggerFake
import com.android.systemui.SysuiTestCase
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder
import com.android.systemui.statusbar.notification.shared.NotificationThrottleHun
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.settings.FakeGlobalSettings
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.invocation.InvocationOnMock
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule

@SmallTest
@RunWithLooper
@RunWith(AndroidJUnit4::class)
@EnableFlags(NotificationThrottleHun.FLAG_NAME)
class AvalancheControllerTest : SysuiTestCase() {

    private val mAvalancheController = AvalancheController()

    // For creating mocks
    @get:Rule var rule: MockitoRule = MockitoJUnit.rule()
    @Mock private val runnableMock: Runnable? = null

    // For creating TestableHeadsUpManager
    @Mock private val mAccessibilityMgr: AccessibilityManagerWrapper? = null
    private val mUiEventLoggerFake = UiEventLoggerFake()
    private val mLogger = Mockito.spy(HeadsUpManagerLogger(logcatLogBuffer()))
    private val mGlobalSettings = FakeGlobalSettings()
    private val mSystemClock = FakeSystemClock()
    private val mExecutor = FakeExecutor(mSystemClock)
    private var testableHeadsUpManager: BaseHeadsUpManager? = null

    @Before
    fun setUp() {
        // Use default non-a11y timeout
        Mockito.`when`(
                mAccessibilityMgr!!.getRecommendedTimeoutMillis(
                    ArgumentMatchers.anyInt(),
                    ArgumentMatchers.anyInt()
                )
            )
            .then { i: InvocationOnMock -> i.getArgument(0) }

        // Initialize TestableHeadsUpManager here instead of at declaration, when mocks will be null
        testableHeadsUpManager =
            TestableHeadsUpManager(
                mContext,
                mLogger,
                mExecutor,
                mGlobalSettings,
                mSystemClock,
                mAccessibilityMgr,
                mUiEventLoggerFake,
                mAvalancheController
            )
    }

    private fun createHeadsUpEntry(id: Int): BaseHeadsUpManager.HeadsUpEntry {
        val entry = testableHeadsUpManager!!.createHeadsUpEntry()

        entry.setEntry(
            NotificationEntryBuilder()
                .setSbn(HeadsUpManagerTestUtil.createSbn(id, Notification.Builder(mContext, "")))
                .build()
        )
        return entry
    }

    @Test
    fun testUpdate_isShowing_runsRunnable() {
        // Entry is showing
        val headsUpEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.headsUpEntryShowing = headsUpEntry

        // Update
        mAvalancheController.update(headsUpEntry, runnableMock!!, "testLabel")

        // Runnable was run
        Mockito.verify(runnableMock, Mockito.times(1)).run()
    }

    @Test
    fun testUpdate_noneShowingAndNotNext_showNow() {
        val headsUpEntry = createHeadsUpEntry(id = 0)

        // None showing
        mAvalancheController.headsUpEntryShowing = null

        // Entry is NOT next
        mAvalancheController.clearNext()

        // Update
        mAvalancheController.update(headsUpEntry, runnableMock!!, "testLabel")

        // Entry is showing now
        Truth.assertThat(mAvalancheController.headsUpEntryShowing).isEqualTo(headsUpEntry)
    }

    @Test
    fun testUpdate_isNext_addsRunnable() {
        // Another entry is already showing
        val otherShowingEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.headsUpEntryShowing = otherShowingEntry

        // Entry is next
        val headsUpEntry = createHeadsUpEntry(id = 1)
        mAvalancheController.addToNext(headsUpEntry, runnableMock!!)

        // Entry has one Runnable
        val runnableList: List<Runnable?>? = mAvalancheController.nextMap[headsUpEntry]
        Truth.assertThat(runnableList).isNotNull()
        Truth.assertThat(runnableList!!.size).isEqualTo(1)

        // Update
        mAvalancheController.update(headsUpEntry, runnableMock, "testLabel")

        // Entry has two Runnables
        Truth.assertThat(runnableList.size).isEqualTo(2)
    }

    @Test
    fun testUpdate_isNotNextWithOtherHunShowing_isNext() {
        val headsUpEntry = createHeadsUpEntry(id = 0)

        // Another entry is already showing
        val otherShowingEntry = createHeadsUpEntry(id = 1)
        mAvalancheController.headsUpEntryShowing = otherShowingEntry

        // Entry is NOT next
        mAvalancheController.clearNext()

        // Update
        mAvalancheController.update(headsUpEntry, runnableMock!!, "testLabel")

        // Entry is next
        Truth.assertThat(mAvalancheController.nextMap.containsKey(headsUpEntry)).isTrue()
    }

    @Test
    fun testDelete_isNext_removedFromNext_runnableNotRun() {
        // Entry is next
        val headsUpEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.addToNext(headsUpEntry, runnableMock!!)

        // Delete
        mAvalancheController.delete(headsUpEntry, runnableMock, "testLabel")

        // Entry was removed from next
        Truth.assertThat(mAvalancheController.nextMap.containsKey(headsUpEntry)).isFalse()

        // Runnable was not run
        Mockito.verify(runnableMock, Mockito.times(0)).run()
    }

    @Test
    fun testDelete_wasDropped_removedFromDropSet() {
        // Entry was dropped
        val headsUpEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.debugDropSet.add(headsUpEntry)

        // Delete
        mAvalancheController.delete(headsUpEntry, runnableMock!!, "testLabel")

        // Entry was removed from dropSet
        Truth.assertThat(mAvalancheController.debugDropSet.contains(headsUpEntry)).isFalse()
    }

    @Test
    fun testDelete_wasDropped_runnableNotRun() {
        // Entry was dropped
        val headsUpEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.debugDropSet.add(headsUpEntry)

        // Delete
        mAvalancheController.delete(headsUpEntry, runnableMock!!, "testLabel")

        // Runnable was not run
        Mockito.verify(runnableMock, Mockito.times(0)).run()
    }

    @Test
    fun testDelete_isShowing_runnableRun() {
        // Entry is showing
        val headsUpEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.headsUpEntryShowing = headsUpEntry

        // Delete
        mAvalancheController.delete(headsUpEntry, runnableMock!!, "testLabel")

        // Runnable was run
        Mockito.verify(runnableMock, Mockito.times(1)).run()
    }

    @Test
    fun testDelete_isShowing_showNext() {
        // Entry is showing
        val showingEntry = createHeadsUpEntry(id = 0)
        mAvalancheController.headsUpEntryShowing = showingEntry

        // There's another entry waiting to show next
        val nextEntry = createHeadsUpEntry(id = 1)
        mAvalancheController.addToNext(nextEntry, runnableMock!!)

        // Delete
        mAvalancheController.delete(showingEntry, runnableMock, "testLabel")

        // Next entry is shown
        Truth.assertThat(mAvalancheController.headsUpEntryShowing).isEqualTo(nextEntry)
    }
}
+3 −4
Original line number Diff line number Diff line
@@ -35,13 +35,10 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.app.ActivityManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Person;
import android.content.Intent;
import android.os.UserHandle;
import android.service.notification.StatusBarNotification;
import android.testing.TestableLooper;

import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -77,6 +74,8 @@ public class BaseHeadsUpManagerTest extends SysuiTestCase {

    private UiEventLoggerFake mUiEventLoggerFake = new UiEventLoggerFake();
    private final HeadsUpManagerLogger mLogger = spy(new HeadsUpManagerLogger(logcatLogBuffer()));
    private AvalancheController mAvalancheController = new AvalancheController();

    @Mock private AccessibilityManagerWrapper mAccessibilityMgr;

    protected static final int TEST_MINIMUM_DISPLAY_TIME = 400;
@@ -99,7 +98,7 @@ public class BaseHeadsUpManagerTest extends SysuiTestCase {

    private BaseHeadsUpManager createHeadsUpManager() {
        return new TestableHeadsUpManager(mContext, mLogger, mExecutor, mGlobalSettings,
                mSystemClock, mAccessibilityMgr, mUiEventLoggerFake);
                mSystemClock, mAccessibilityMgr, mUiEventLoggerFake, mAvalancheController);
    }

    private NotificationEntry createStickyEntry(int id) {
+10 −4
Original line number Diff line number Diff line
@@ -28,8 +28,8 @@ import static org.mockito.Mockito.when;
import android.content.Context;
import android.testing.TestableLooper;

import androidx.test.filters.SmallTest;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;

import com.android.internal.logging.UiEventLogger;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
@@ -76,6 +76,7 @@ public class HeadsUpManagerPhoneTest extends BaseHeadsUpManagerTest {
    @Mock private UiEventLogger mUiEventLogger;
    @Mock private JavaAdapter mJavaAdapter;
    @Mock private ShadeInteractor mShadeInteractor;
    private AvalancheController mAvalancheController = new AvalancheController();

    private static final class TestableHeadsUpManagerPhone extends HeadsUpManagerPhone {
        TestableHeadsUpManagerPhone(
@@ -92,7 +93,8 @@ public class HeadsUpManagerPhoneTest extends BaseHeadsUpManagerTest {
                AccessibilityManagerWrapper accessibilityManagerWrapper,
                UiEventLogger uiEventLogger,
                JavaAdapter javaAdapter,
                ShadeInteractor shadeInteractor
                ShadeInteractor shadeInteractor,
                AvalancheController avalancheController
        ) {
            super(
                    context,
@@ -109,7 +111,8 @@ public class HeadsUpManagerPhoneTest extends BaseHeadsUpManagerTest {
                    accessibilityManagerWrapper,
                    uiEventLogger,
                    javaAdapter,
                    shadeInteractor
                    shadeInteractor,
                    avalancheController
            );
            mMinimumDisplayTime = TEST_MINIMUM_DISPLAY_TIME;
            mAutoDismissTime = TEST_AUTO_DISMISS_TIME;
@@ -131,12 +134,15 @@ public class HeadsUpManagerPhoneTest extends BaseHeadsUpManagerTest {
                mAccessibilityManagerWrapper,
                mUiEventLogger,
                mJavaAdapter,
                mShadeInteractor
                mShadeInteractor,
                mAvalancheController
        );
    }

    @Before
    public void setUp() {
        // TODO(b/315362456) create separate test with the flag disabled
        //  then modify this file to test with the flag enabled
        mSetFlagsRule.disableFlags(NotificationThrottleHun.FLAG_NAME);

        when(mShadeInteractor.isAnyExpanded()).thenReturn(StateFlowKt.MutableStateFlow(false));
+3 −2
Original line number Diff line number Diff line
@@ -43,9 +43,10 @@ class TestableHeadsUpManager extends BaseHeadsUpManager {
            GlobalSettings globalSettings,
            SystemClock systemClock,
            AccessibilityManagerWrapper accessibilityManagerWrapper,
            UiEventLogger uiEventLogger) {
            UiEventLogger uiEventLogger,
            AvalancheController avalancheController) {
        super(context, logger, mockExecutorHandler(executor), globalSettings, systemClock,
                executor, accessibilityManagerWrapper, uiEventLogger);
                executor, accessibilityManagerWrapper, uiEventLogger, avalancheController);

        mTouchAcceptanceDelay = BaseHeadsUpManagerTest.TEST_TOUCH_ACCEPTANCE_TIME;
        mMinimumDisplayTime = BaseHeadsUpManagerTest.TEST_MINIMUM_DISPLAY_TIME;
+7 −5
Original line number Diff line number Diff line
@@ -43,6 +43,7 @@ import com.android.systemui.statusbar.notification.collection.render.GroupMember
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.policy.AccessibilityManagerWrapper;
import com.android.systemui.statusbar.policy.AnimationStateHandler;
import com.android.systemui.statusbar.policy.AvalancheController;
import com.android.systemui.statusbar.policy.BaseHeadsUpManager;
import com.android.systemui.statusbar.policy.ConfigurationController;
import com.android.systemui.statusbar.policy.HeadsUpManagerLogger;
@@ -124,9 +125,10 @@ public class HeadsUpManagerPhone extends BaseHeadsUpManager implements OnHeadsUp
            AccessibilityManagerWrapper accessibilityManagerWrapper,
            UiEventLogger uiEventLogger,
            JavaAdapter javaAdapter,
            ShadeInteractor shadeInteractor) {
            ShadeInteractor shadeInteractor,
            AvalancheController avalancheController) {
        super(context, logger, handler, globalSettings, systemClock, executor,
                accessibilityManagerWrapper, uiEventLogger);
                accessibilityManagerWrapper, uiEventLogger, avalancheController);
        Resources resources = mContext.getResources();
        mExtensionTime = resources.getInteger(R.integer.ambient_notification_extension_time);
        statusBarStateController.addCallback(mStatusBarStateListener);
@@ -279,7 +281,7 @@ public class HeadsUpManagerPhone extends BaseHeadsUpManager implements OnHeadsUp
        if (headsUpEntry != null && headsUpEntry.mRemoteInputActive != remoteInputActive) {
            headsUpEntry.mRemoteInputActive = remoteInputActive;
            if (remoteInputActive) {
                headsUpEntry.removeAutoRemovalCallbacks("setRemoteInputActive(true)");
                headsUpEntry.cancelAutoRemovalCallbacks("setRemoteInputActive(true)");
            } else {
                headsUpEntry.updateEntry(false /* updatePostTime */, "setRemoteInputActive(false)");
            }
@@ -482,7 +484,7 @@ public class HeadsUpManagerPhone extends BaseHeadsUpManager implements OnHeadsUp

            this.mExpanded = expanded;
            if (expanded) {
                removeAutoRemovalCallbacks("setExpanded(true)");
                cancelAutoRemovalCallbacks("setExpanded(true)");
            } else {
                updateEntry(false /* updatePostTime */, "setExpanded(false)");
            }
@@ -495,7 +497,7 @@ public class HeadsUpManagerPhone extends BaseHeadsUpManager implements OnHeadsUp

            mGutsShownPinned = gutsShownPinned;
            if (gutsShownPinned) {
                removeAutoRemovalCallbacks("setGutsShownPinned(true)");
                cancelAutoRemovalCallbacks("setGutsShownPinned(true)");
            } else {
                updateEntry(false /* updatePostTime */, "setGutsShownPinned(false)");
            }
Loading