diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerExt.kt b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerExt.kt new file mode 100644 index 0000000000000..6148b407d3bf5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerExt.kt @@ -0,0 +1,36 @@ +/* + * 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 + get() = conflatedCallbackFlow { + val listener = + object : StatusBarStateController.StateListener { + override fun onExpandedChanged(isExpanded: Boolean) { + trySend(isExpanded) + } + } + trySend(isExpanded) + addCallback(listener) + awaitClose { removeCallback(listener) } + } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinator.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinator.kt index e996b78133f65..6bf7668f080c4 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinator.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinator.kt @@ -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 = - conflatedCallbackFlow { - val observer = - object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - trySend(Unit) - } - } - registerContentObserverForUser(name, observer, userHandle) - awaitClose { unregisterContentObserver(observer) } - } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/HeadsUpManagerExt.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/HeadsUpManagerExt.kt new file mode 100644 index 0000000000000..5e367509e774f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/HeadsUpManagerExt.kt @@ -0,0 +1,38 @@ +/* + * 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> + get() = conflatedCallbackFlow { + val listener = + object : OnHeadsUpChangedListener { + override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) { + trySend(entry to isHeadsUp) + } + } + addListener(listener) + awaitClose { removeListener(listener) } + } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.kt index 2686238c0d3cb..49da848baca75 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.kt @@ -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().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(