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

Commit 7833aa0e authored by Steve Elliott's avatar Steve Elliott
Browse files

Filter out seen notifications from keyguard

Bug: 254647461
Test: atest KeyguardCoordinatorTest
Change-Id: I43e2bd78f35aec1f8aa8c21240335d8e3433abfd
parent b96af8ad
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -90,7 +90,8 @@ object Flags {
    // TODO(b/257315550): Tracking Bug
    val NO_HUN_FOR_OLD_WHEN = unreleasedFlag(118, "no_hun_for_old_when")

    // next id: 119
    val FILTER_UNSEEN_NOTIFS_ON_KEYGUARD =
        unreleasedFlag(254647461, "filter_unseen_notifs_on_keyguard", teamfood = true)

    // 200 - keyguard/lockscreen
    // ** Flag retired **
+4 −0
Original line number Diff line number Diff line
@@ -40,4 +40,8 @@ class NotifPipelineFlags @Inject constructor(
    val isSemiStableSortEnabled: Boolean by lazy {
        featureFlags.isEnabled(Flags.SEMI_STABLE_SORT)
    }

    val shouldFilterUnseenNotifsOnKeyguard: Boolean by lazy {
        featureFlags.isEnabled(Flags.FILTER_UNSEEN_NOTIFS_ON_KEYGUARD)
    }
}
+86 −10
Original line number Diff line number Diff line
@@ -13,38 +13,113 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.notification.collection.coordinator

import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider
import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider
import androidx.annotation.VisibleForTesting
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.keyguard.data.repository.KeyguardRepository
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.statusbar.StatusBarState
import com.android.systemui.statusbar.notification.NotifPipelineFlags
import com.android.systemui.statusbar.notification.collection.NotifPipeline
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider
import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

/**
 * Filters low priority and privacy-sensitive notifications from the lockscreen, and hides section
 * headers on the lockscreen.
 */
@CoordinatorScope
class KeyguardCoordinator @Inject constructor(
class KeyguardCoordinator
@Inject
constructor(
    private val keyguardNotificationVisibilityProvider: KeyguardNotificationVisibilityProvider,
    private val keyguardRepository: KeyguardRepository,
    private val notifPipelineFlags: NotifPipelineFlags,
    @Application private val scope: CoroutineScope,
    private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider,
    private val statusBarStateController: StatusBarStateController
    private val statusBarStateController: StatusBarStateController,
) : Coordinator {

    private val unseenNotifications = mutableSetOf<NotificationEntry>()

    override fun attach(pipeline: NotifPipeline) {
        setupInvalidateNotifListCallbacks()
        // Filter at the "finalize" stage so that views remain bound by PreparationCoordinator
        pipeline.addFinalizeFilter(notifFilter)
        keyguardNotificationVisibilityProvider.addOnStateChangedListener(::invalidateListFromFilter)
        updateSectionHeadersVisibility()
        if (notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard) {
            attachUnseenFilter(pipeline)
        }
    }

    private fun attachUnseenFilter(pipeline: NotifPipeline) {
        pipeline.addFinalizeFilter(unseenNotifFilter)
        pipeline.addCollectionListener(collectionListener)
        scope.launch { clearUnseenWhenKeyguardIsDismissed() }
    }

    private suspend fun clearUnseenWhenKeyguardIsDismissed() {
        // Use collectLatest so that the suspending block is cancelled if isKeyguardShowing changes
        // during the timeout period
        keyguardRepository.isKeyguardShowing.collectLatest { isKeyguardShowing ->
            if (!isKeyguardShowing) {
                unseenNotifFilter.invalidateList("keyguard no longer showing")
                delay(SEEN_TIMEOUT)
                unseenNotifications.clear()
            }
        }
    }

    private val collectionListener =
        object : NotifCollectionListener {
            override fun onEntryAdded(entry: NotificationEntry) {
                if (keyguardRepository.isKeyguardShowing()) {
                    unseenNotifications.add(entry)
                }
            }

            override fun onEntryUpdated(entry: NotificationEntry) {
                if (keyguardRepository.isKeyguardShowing()) {
                    unseenNotifications.add(entry)
                }
            }

            override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
                unseenNotifications.remove(entry)
            }
        }

    @VisibleForTesting
    internal val unseenNotifFilter =
        object : NotifFilter("$TAG-unseen") {
            override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean =
                when {
                    // Don't apply filter if the keyguard isn't currently showing
                    !keyguardRepository.isKeyguardShowing() -> false
                    // Don't apply the filter if the notification is unseen
                    unseenNotifications.contains(entry) -> false
                    // Don't apply the filter to (non-promoted) group summaries
                    //  - summary will be pruned if necessary, depending on if children are filtered
                    entry.parent?.summary == entry -> false
                    else -> true
                }
        }

    private val notifFilter: NotifFilter = object : NotifFilter(TAG) {
    private val notifFilter: NotifFilter =
        object : NotifFilter(TAG) {
            override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean =
                keyguardNotificationVisibilityProvider.shouldHideNotification(entry)
        }
@@ -67,5 +142,6 @@ class KeyguardCoordinator @Inject constructor(

    companion object {
        private const val TAG = "KeyguardCoordinator"
        private val SEEN_TIMEOUT = 5.seconds
    }
}
+188 −24
Original line number Diff line number Diff line
@@ -13,57 +13,55 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.systemui.statusbar.notification.collection.coordinator

import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.statusbar.StatusBarState
import com.android.systemui.statusbar.notification.NotifPipelineFlags
import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder
import com.android.systemui.statusbar.notification.collection.NotifPipeline
import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider
import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.withArgCaptor
import java.util.function.Consumer
import org.junit.Before
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.clearInvocations
import org.mockito.Mockito.verify
import java.util.function.Consumer
import kotlin.time.Duration.Companion.seconds
import org.mockito.Mockito.`when` as whenever

@SmallTest
@RunWith(AndroidTestingRunner::class)
class KeyguardCoordinatorTest : SysuiTestCase() {
    private val notifPipeline: NotifPipeline = mock()

    private val keyguardNotifVisibilityProvider: KeyguardNotificationVisibilityProvider = mock()
    private val keyguardRepository = FakeKeyguardRepository()
    private val notifPipelineFlags: NotifPipelineFlags = mock()
    private val notifPipeline: NotifPipeline = mock()
    private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider = mock()
    private val statusBarStateController: StatusBarStateController = mock()

    private lateinit var onStateChangeListener: Consumer<String>
    private lateinit var keyguardFilter: NotifFilter

    @Before
    fun setup() {
        val keyguardCoordinator = KeyguardCoordinator(
            keyguardNotifVisibilityProvider,
            sectionHeaderVisibilityProvider,
            statusBarStateController
        )
        keyguardCoordinator.attach(notifPipeline)
        onStateChangeListener = withArgCaptor {
            verify(keyguardNotifVisibilityProvider).addOnStateChangedListener(capture())
        }
        keyguardFilter = withArgCaptor {
            verify(notifPipeline).addFinalizeFilter(capture())
        }
    }

    @Test
    fun testSetSectionHeadersVisibleInShade() {
    fun testSetSectionHeadersVisibleInShade() = runKeyguardCoordinatorTest {
        clearInvocations(sectionHeaderVisibilityProvider)
        whenever(statusBarStateController.state).thenReturn(StatusBarState.SHADE)
        onStateChangeListener.accept("state change")
@@ -71,10 +69,176 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    }

    @Test
    fun testSetSectionHeadersNotVisibleOnKeyguard() {
    fun testSetSectionHeadersNotVisibleOnKeyguard() = runKeyguardCoordinatorTest {
        clearInvocations(sectionHeaderVisibilityProvider)
        whenever(statusBarStateController.state).thenReturn(StatusBarState.KEYGUARD)
        onStateChangeListener.accept("state change")
        verify(sectionHeaderVisibilityProvider).sectionHeadersVisible = eq(false)
    }

    @Test
    fun unseenFilterSuppressesSeenNotifWhileKeyguardShowing() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and a notification is present
        keyguardRepository.setKeyguardShowing(false)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

            // WHEN: The keyguard is now showing
            keyguardRepository.setKeyguardShowing(true)
            testScheduler.runCurrent()

            // THEN: The notification is recognized as "seen" and is filtered out.
            assertThat(unseenFilter.shouldFilterOut(fakeEntry, 0L)).isTrue()

            // WHEN: The keyguard goes away
            keyguardRepository.setKeyguardShowing(false)
            testScheduler.runCurrent()

            // THEN: The notification is shown regardless
            assertThat(unseenFilter.shouldFilterOut(fakeEntry, 0L)).isFalse()
        }
    }

    @Test
    fun unseenFilterAllowsNewNotif() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is showing, no notifications present
        keyguardRepository.setKeyguardShowing(true)
        runKeyguardCoordinatorTest {
            // WHEN: A new notification is posted
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

            // THEN: The notification is recognized as "unseen" and is not filtered out.
            assertThat(unseenFilter.shouldFilterOut(fakeEntry, 0L)).isFalse()
        }
    }

    @Test
    fun unseenFilterSeenGroupSummaryWithUnseenChild() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and a notification is present
        keyguardRepository.setKeyguardShowing(false)
        runKeyguardCoordinatorTest {
            // WHEN: A new notification is posted
            val fakeSummary = NotificationEntryBuilder().build()
            val fakeChild = NotificationEntryBuilder()
                    .setGroup(context, "group")
                    .setGroupSummary(context, false)
                    .build()
            GroupEntryBuilder()
                    .setSummary(fakeSummary)
                    .addChild(fakeChild)
                    .build()

            collectionListener.onEntryAdded(fakeSummary)
            collectionListener.onEntryAdded(fakeChild)

            // WHEN: Keyguard is now showing, both notifications are marked as seen
            keyguardRepository.setKeyguardShowing(true)
            testScheduler.runCurrent()

            // WHEN: The child notification is now unseen
            collectionListener.onEntryUpdated(fakeChild)

            // THEN: The summary is not filtered out, because the child is unseen
            assertThat(unseenFilter.shouldFilterOut(fakeSummary, 0L)).isFalse()
        }
    }

    @Test
    fun unseenNotificationIsMarkedAsSeenWhenKeyguardGoesAway() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is showing, unseen notification is present
        keyguardRepository.setKeyguardShowing(true)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

            // WHEN: Keyguard is no longer showing for 5 seconds
            keyguardRepository.setKeyguardShowing(false)
            testScheduler.runCurrent()
            testScheduler.advanceTimeBy(5.seconds.inWholeMilliseconds)
            testScheduler.runCurrent()

            // WHEN: Keyguard is shown again
            keyguardRepository.setKeyguardShowing(true)
            testScheduler.runCurrent()

            // THEN: The notification is now recognized as "seen" and is filtered out.
            assertThat(unseenFilter.shouldFilterOut(fakeEntry, 0L)).isTrue()
        }
    }

    @Test
    fun unseenNotificationIsNotMarkedAsSeenIfTimeThresholdNotMet() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is showing, unseen notification is present
        keyguardRepository.setKeyguardShowing(true)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

            // WHEN: Keyguard is no longer showing for <5 seconds
            keyguardRepository.setKeyguardShowing(false)
            testScheduler.runCurrent()
            testScheduler.advanceTimeBy(1.seconds.inWholeMilliseconds)

            // WHEN: Keyguard is shown again
            keyguardRepository.setKeyguardShowing(true)
            testScheduler.runCurrent()

            // THEN: The notification is not recognized as "seen" and is not filtered out.
            assertThat(unseenFilter.shouldFilterOut(fakeEntry, 0L)).isFalse()
        }
    }

    private fun runKeyguardCoordinatorTest(
        testBlock: suspend KeyguardCoordinatorTestScope.() -> Unit
    ) {
        val testScope = TestScope(UnconfinedTestDispatcher())
        val keyguardCoordinator =
            KeyguardCoordinator(
                keyguardNotifVisibilityProvider,
                keyguardRepository,
                notifPipelineFlags,
                testScope.backgroundScope,
                sectionHeaderVisibilityProvider,
                statusBarStateController,
            )
        keyguardCoordinator.attach(notifPipeline)
        KeyguardCoordinatorTestScope(keyguardCoordinator, testScope).run {
            testScheduler.advanceUntilIdle()
            testScope.runTest(dispatchTimeoutMs = 1.seconds.inWholeMilliseconds) { testBlock() }
        }
    }

    private inner class KeyguardCoordinatorTestScope(
        private val keyguardCoordinator: KeyguardCoordinator,
        private val scope: TestScope,
    ) : CoroutineScope by scope {
        val testScheduler: TestCoroutineScheduler
            get() = scope.testScheduler

        val onStateChangeListener: Consumer<String> =
            withArgCaptor {
                verify(keyguardNotifVisibilityProvider).addOnStateChangedListener(capture())
            }

        val unseenFilter: NotifFilter
            get() = keyguardCoordinator.unseenNotifFilter

        // TODO(254647461): Remove lazy once Flags.FILTER_UNSEEN_NOTIFS_ON_KEYGUARD is enabled and
        //  removed
        val collectionListener: NotifCollectionListener by lazy {
            withArgCaptor { verify(notifPipeline).addCollectionListener(capture()) }
        }
    }
}