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

Commit 0ef3a2c0 authored by Steve Elliott's avatar Steve Elliott
Browse files

Base "seen" logic on shade expansion and heads up

Before: all notifications are marked as seen when device is unlocked for 5 seconds

After: HUNs are immediately marked as seen, opening the shade will immediately mark all notifications as seen.

Fixes: 267963644
Test: CtsVerifier > Notification Privacy Test
Test: atest KeyguardCoordinatorTest
Change-Id: I71ef1b7a56c125ac8a5182f9a86282f95b8f9d08
parent 2c26442b
Loading
Loading
Loading
Loading
+36 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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 com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.plugins.statusbar.StatusBarStateController
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow

/** Returns a [Flow] that emits whenever [StatusBarStateController.isExpanded] changes value. */
val StatusBarStateController.expansionChanges: Flow<Boolean>
    get() = conflatedCallbackFlow {
        val listener =
            object : StatusBarStateController.StateListener {
                override fun onExpandedChanged(isExpanded: Boolean) {
                    trySend(isExpanded)
                }
            }
        trySend(isExpanded)
        addCallback(listener)
        awaitClose { removeCallback(listener) }
    }
+44 −29
Original line number Diff line number Diff line
@@ -16,16 +16,15 @@

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

import android.database.ContentObserver
import android.os.UserHandle
import android.provider.Settings
import androidx.annotation.VisibleForTesting
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
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.expansionChanges
import com.android.systemui.statusbar.notification.NotifPipelineFlags
import com.android.systemui.statusbar.notification.collection.NotifPipeline
import com.android.systemui.statusbar.notification.collection.NotificationEntry
@@ -35,15 +34,14 @@ import com.android.systemui.statusbar.notification.collection.notifcollection.No
import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider
import com.android.systemui.statusbar.notification.collection.provider.SeenNotificationsProviderImpl
import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider
import com.android.systemui.statusbar.policy.HeadsUpManager
import com.android.systemui.statusbar.policy.headsUpEvents
import com.android.systemui.util.settings.SecureSettings
import com.android.systemui.util.settings.SettingsProxy
import com.android.systemui.util.settings.SettingsProxyExt.observerFlow
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
@@ -60,6 +58,7 @@ class KeyguardCoordinator
@Inject
constructor(
    @Background private val bgDispatcher: CoroutineDispatcher,
    private val headsUpManager: HeadsUpManager,
    private val keyguardNotificationVisibilityProvider: KeyguardNotificationVisibilityProvider,
    private val keyguardRepository: KeyguardRepository,
    private val notifPipelineFlags: NotifPipelineFlags,
@@ -87,28 +86,53 @@ constructor(
    private fun attachUnseenFilter(pipeline: NotifPipeline) {
        pipeline.addFinalizeFilter(unseenNotifFilter)
        pipeline.addCollectionListener(collectionListener)
        scope.launch { clearUnseenWhenKeyguardIsDismissed() }
        scope.launch { trackUnseenNotificationsWhileUnlocked() }
        scope.launch { invalidateWhenUnseenSettingChanges() }
    }

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

    private suspend fun trackUnseenNotifications() {
        coroutineScope {
            launch { clearUnseenNotificationsWhenShadeIsExpanded() }
            launch { markHeadsUpNotificationsAsSeen() }
        }
    }

    private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() {
        statusBarStateController.expansionChanges.collect { isExpanded ->
            if (isExpanded) {
                unseenNotifications.clear()
            }
        }
    }

    private suspend fun markHeadsUpNotificationsAsSeen() {
        headsUpManager.allEntries
            .filter { it.isRowPinned }
            .forEach { unseenNotifications.remove(it) }
        headsUpManager.headsUpEvents.collect { (entry, isHun) ->
            if (isHun) {
                unseenNotifications.remove(entry)
            }
        }
    }

    private suspend fun invalidateWhenUnseenSettingChanges() {
        secureSettings
            // emit whenever the setting has changed
            .settingChangesForUser(
                Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS,
            .observerFlow(
                UserHandle.USER_ALL,
                Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS,
            )
            // perform a query immediately
            .onStart { emit(Unit) }
@@ -136,13 +160,17 @@ constructor(
    private val collectionListener =
        object : NotifCollectionListener {
            override fun onEntryAdded(entry: NotificationEntry) {
                if (keyguardRepository.isKeyguardShowing()) {
                if (
                    keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded
                ) {
                    unseenNotifications.add(entry)
                }
            }

            override fun onEntryUpdated(entry: NotificationEntry) {
                if (keyguardRepository.isKeyguardShowing()) {
                if (
                    keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded
                ) {
                    unseenNotifications.add(entry)
                }
            }
@@ -212,18 +240,5 @@ constructor(

    companion object {
        private const val TAG = "KeyguardCoordinator"
        private val SEEN_TIMEOUT = 5.seconds
    }
}

private fun SettingsProxy.settingChangesForUser(name: String, userHandle: Int): Flow<Unit> =
    conflatedCallbackFlow {
        val observer =
            object : ContentObserver(null) {
                override fun onChange(selfChange: Boolean) {
                    trySend(Unit)
                }
    }
        registerContentObserverForUser(name, observer, userHandle)
        awaitClose { unregisterContentObserver(observer) }
}
+38 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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 com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow

/**
 * Returns a [Flow] that emits events whenever a [NotificationEntry] enters or exists the "heads up"
 * state.
 */
val HeadsUpManager.headsUpEvents: Flow<Pair<NotificationEntry, Boolean>>
    get() = conflatedCallbackFlow {
        val listener =
            object : OnHeadsUpChangedListener {
                override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) {
                    trySend(entry to isHeadsUp)
                }
            }
        addListener(listener)
        awaitClose { removeListener(listener) }
    }
+65 −16
Original line number Diff line number Diff line
@@ -38,6 +38,8 @@ import com.android.systemui.statusbar.notification.collection.provider.SeenNotif
import com.android.systemui.statusbar.notification.collection.provider.SeenNotificationsProviderImpl
import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
import com.android.systemui.statusbar.policy.HeadsUpManager
import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.withArgCaptor
@@ -63,6 +65,7 @@ import org.mockito.Mockito.`when` as whenever
@RunWith(AndroidTestingRunner::class)
class KeyguardCoordinatorTest : SysuiTestCase() {

    private val headsUpManager: HeadsUpManager = mock()
    private val keyguardNotifVisibilityProvider: KeyguardNotificationVisibilityProvider = mock()
    private val keyguardRepository = FakeKeyguardRepository()
    private val notifPipelineFlags: NotifPipelineFlags = mock()
@@ -90,8 +93,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    fun unseenFilterSuppressesSeenNotifWhileKeyguardShowing() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and a notification is present
        // GIVEN: Keyguard is not showing, shade is expanded, and a notification is present
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(true)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)
@@ -112,12 +116,45 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
        }
    }

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

        // GIVEN: Keyguard is not showing, shade is not expanded
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(false)
        runKeyguardCoordinatorTest {
            // WHEN: A notification is posted
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

            // WHEN: That notification is heads up
            onHeadsUpChangedListener.onHeadsUpStateChanged(fakeEntry, /* isHeadsUp= */ true)
            testScheduler.runCurrent()

            // 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 unseenFilterDoesNotSuppressSeenOngoingNotifWhileKeyguardShowing() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and an ongoing notification is present
        // GIVEN: Keyguard is not showing, shade is expanded, and an ongoing notification is present
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(true)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder()
                .setNotification(Notification.Builder(mContext).setOngoing(true).build())
@@ -137,8 +174,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    fun unseenFilterDoesNotSuppressSeenMediaNotifWhileKeyguardShowing() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and a media notification is present
        // GIVEN: Keyguard is not showing, shade is expanded, and a media notification is present
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(true)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder().build().apply {
                row = mock<ExpandableNotificationRow>().apply {
@@ -160,8 +198,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    fun unseenFilterUpdatesSeenProviderWhenSuppressing() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and a notification is present
        // GIVEN: Keyguard is not showing, shade is expanded, and a notification is present
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(true)
        runKeyguardCoordinatorTest {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)
@@ -185,8 +224,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    fun unseenFilterInvalidatesWhenSettingChanges() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing
        // GIVEN: Keyguard is not showing, and shade is expanded
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(true)
        runKeyguardCoordinatorTest {
            // GIVEN: A notification is present
            val fakeEntry = NotificationEntryBuilder().build()
@@ -237,8 +277,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    fun unseenFilterSeenGroupSummaryWithUnseenChild() {
        whenever(notifPipelineFlags.shouldFilterUnseenNotifsOnKeyguard).thenReturn(true)

        // GIVEN: Keyguard is not showing, and a notification is present
        // GIVEN: Keyguard is not showing, shade is expanded, and a notification is present
        keyguardRepository.setKeyguardShowing(false)
        whenever(statusBarStateController.isExpanded).thenReturn(true)
        runKeyguardCoordinatorTest {
            // WHEN: A new notification is posted
            val fakeSummary = NotificationEntryBuilder().build()
@@ -276,11 +317,11 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

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

            // When: Shade is expanded
            statusBarStateListener.onExpandedChanged(true)

            // WHEN: Keyguard is shown again
            keyguardRepository.setKeyguardShowing(true)
@@ -292,7 +333,7 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
    }

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

        // GIVEN: Keyguard is showing, unseen notification is present
@@ -301,10 +342,8 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
            val fakeEntry = NotificationEntryBuilder().build()
            collectionListener.onEntryAdded(fakeEntry)

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

            // WHEN: Keyguard is shown again
            keyguardRepository.setKeyguardShowing(true)
@@ -327,6 +366,7 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
        val keyguardCoordinator =
            KeyguardCoordinator(
                testDispatcher,
                headsUpManager,
                keyguardNotifVisibilityProvider,
                keyguardRepository,
                notifPipelineFlags,
@@ -364,12 +404,21 @@ class KeyguardCoordinatorTest : SysuiTestCase() {
        val unseenFilter: NotifFilter
            get() = keyguardCoordinator.unseenNotifFilter

        // TODO(254647461): Remove lazy once Flags.FILTER_UNSEEN_NOTIFS_ON_KEYGUARD is enabled and
        //  removed
        // TODO(254647461): Remove lazy from these properties once
        //    Flags.FILTER_UNSEEN_NOTIFS_ON_KEYGUARD is enabled and removed

        val collectionListener: NotifCollectionListener by lazy {
            withArgCaptor { verify(notifPipeline).addCollectionListener(capture()) }
        }

        val onHeadsUpChangedListener: OnHeadsUpChangedListener by lazy {
            withArgCaptor { verify(headsUpManager).addListener(capture()) }
        }

        val statusBarStateListener: StatusBarStateController.StateListener by lazy {
            withArgCaptor { verify(statusBarStateController).addCallback(capture()) }
        }

        var showOnlyUnseenNotifsOnKeyguardSetting: Boolean
            get() =
                fakeSettings.getIntForUser(