diff options
3 files changed, 456 insertions, 113 deletions
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 2fa070ca20b5..07eb8a00a178 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 @@ -28,12 +28,9 @@ import com.android.systemui.dump.DumpManager import com.android.systemui.keyguard.data.repository.KeyguardRepository import com.android.systemui.keyguard.data.repository.KeyguardTransitionRepository import com.android.systemui.keyguard.shared.model.KeyguardState -import com.android.systemui.keyguard.shared.model.TransitionState -import com.android.systemui.keyguard.shared.model.TransitionStep 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 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope @@ -50,30 +47,29 @@ import com.android.systemui.util.settings.SecureSettings import com.android.systemui.util.settings.SettingsProxyExt.observerFlow import java.io.PrintWriter import javax.inject.Inject -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.yield /** * Filters low priority and privacy-sensitive notifications from the lockscreen, and hides section - * headers on the lockscreen. + * headers on the lockscreen. If enabled, it will also track and hide seen notifications on the + * lockscreen. */ @CoordinatorScope class KeyguardCoordinator @@ -86,7 +82,6 @@ constructor( private val keyguardRepository: KeyguardRepository, private val keyguardTransitionRepository: KeyguardTransitionRepository, private val logger: KeyguardCoordinatorLogger, - private val notifPipelineFlags: NotifPipelineFlags, @Application private val scope: CoroutineScope, private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider, private val secureSettings: SecureSettings, @@ -95,6 +90,8 @@ constructor( ) : Coordinator, Dumpable { private val unseenNotifications = mutableSetOf<NotificationEntry>() + private val unseenEntryAdded = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) + private val unseenEntryRemoved = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) private var unseenFilterEnabled = false override fun attach(pipeline: NotifPipeline) { @@ -109,79 +106,130 @@ constructor( private fun attachUnseenFilter(pipeline: NotifPipeline) { pipeline.addFinalizeFilter(unseenNotifFilter) pipeline.addCollectionListener(collectionListener) - scope.launch { trackUnseenNotificationsWhileUnlocked() } - scope.launch { invalidateWhenUnseenSettingChanges() } + scope.launch { trackUnseenFilterSettingChanges() } dumpManager.registerDumpable(this) } - private suspend fun trackUnseenNotificationsWhileUnlocked() { - // Whether or not we're actively tracking unseen notifications to mark them as seen when - // appropriate. - val isTrackingUnseen: Flow<Boolean> = - keyguardRepository.isKeyguardShowing - // transformLatest so that we can cancel listening to keyguard transitions once - // isKeyguardShowing changes (after a successful transition to the keyguard). - .transformLatest { isShowing -> - if (isShowing) { - // If the keyguard is showing, we're not tracking unseen. - emit(false) - } else { - // If the keyguard stops showing, then start tracking unseen notifications. - emit(true) - // If the screen is turning off, stop tracking, but if that transition is - // cancelled, then start again. - emitAll( - keyguardTransitionRepository.transitions.map { step -> - !step.isScreenTurningOff - } - ) - } - } - // Prevent double emit of `false` caused by transition to AOD, followed by keyguard - // showing + private suspend fun trackSeenNotifications() { + // Whether or not keyguard is visible (or occluded). + val isKeyguardPresent: Flow<Boolean> = + keyguardTransitionRepository.transitions + .map { step -> step.to != KeyguardState.GONE } .distinctUntilChanged() .onEach { trackingUnseen -> logger.logTrackingUnseen(trackingUnseen) } - // Use collectLatest so that trackUnseenNotifications() is cancelled when the keyguard is - // showing again - var clearUnseenOnBeginTracking = false - isTrackingUnseen.collectLatest { trackingUnseen -> - if (!trackingUnseen) { - // Wait for the user to spend enough time on the lock screen before clearing unseen - // set when unlocked - awaitTimeSpentNotDozing(SEEN_TIMEOUT) - clearUnseenOnBeginTracking = true - logger.logSeenOnLockscreen() + // Separately track seen notifications while the device is locked, applying once the device + // is unlocked. + val notificationsSeenWhileLocked = mutableSetOf<NotificationEntry>() + + // Use [collectLatest] to cancel any running jobs when [trackingUnseen] changes. + isKeyguardPresent.collectLatest { isKeyguardPresent: Boolean -> + if (isKeyguardPresent) { + // Keyguard is not gone, notifications need to be visible for a certain threshold + // before being marked as seen + trackSeenNotificationsWhileLocked(notificationsSeenWhileLocked) } else { - if (clearUnseenOnBeginTracking) { - clearUnseenOnBeginTracking = false - logger.logAllMarkedSeenOnUnlock() - unseenNotifications.clear() + // Mark all seen-while-locked notifications as seen for real. + if (notificationsSeenWhileLocked.isNotEmpty()) { + unseenNotifications.removeAll(notificationsSeenWhileLocked) + logger.logAllMarkedSeenOnUnlock( + seenCount = notificationsSeenWhileLocked.size, + remainingUnseenCount = unseenNotifications.size + ) + notificationsSeenWhileLocked.clear() } unseenNotifFilter.invalidateList("keyguard no longer showing") - trackUnseenNotifications() + // Keyguard is gone, notifications can be immediately marked as seen when they + // become visible. + trackSeenNotificationsWhileUnlocked() } } } - private suspend fun awaitTimeSpentNotDozing(duration: Duration) { - keyguardRepository.isDozing - // Use transformLatest so that the timeout delay is cancelled if the device enters doze, - // and is restarted when doze ends. - .transformLatest { isDozing -> - if (!isDozing) { - delay(duration) - // Signal timeout has completed - emit(Unit) + /** + * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually + * been "seen" while the device is on the keyguard. + */ + private suspend fun trackSeenNotificationsWhileLocked( + notificationsSeenWhileLocked: MutableSet<NotificationEntry>, + ) = coroutineScope { + // Remove removed notifications from the set + launch { + unseenEntryRemoved.collect { entry -> + if (notificationsSeenWhileLocked.remove(entry)) { + logger.logRemoveSeenOnLockscreen(entry) + } + } + } + // Use collectLatest so that the timeout delay is cancelled if the device enters doze, and + // is restarted when doze ends. + keyguardRepository.isDozing.collectLatest { isDozing -> + if (!isDozing) { + trackSeenNotificationsWhileLockedAndNotDozing(notificationsSeenWhileLocked) + } + } + } + + /** + * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually + * been "seen" while the device is on the keyguard and not dozing. Any new and existing unseen + * notifications are not marked as seen until they are visible for the [SEEN_TIMEOUT] duration. + */ + private suspend fun trackSeenNotificationsWhileLockedAndNotDozing( + notificationsSeenWhileLocked: MutableSet<NotificationEntry> + ) = coroutineScope { + // All child tracking jobs will be cancelled automatically when this is cancelled. + val trackingJobsByEntry = mutableMapOf<NotificationEntry, Job>() + + /** + * Wait for the user to spend enough time on the lock screen before removing notification + * from unseen set upon unlock. + */ + suspend fun trackSeenDurationThreshold(entry: NotificationEntry) { + if (notificationsSeenWhileLocked.remove(entry)) { + logger.logResetSeenOnLockscreen(entry) + } + delay(SEEN_TIMEOUT) + notificationsSeenWhileLocked.add(entry) + trackingJobsByEntry.remove(entry) + logger.logSeenOnLockscreen(entry) + } + + /** Stop any unseen tracking when a notification is removed. */ + suspend fun stopTrackingRemovedNotifs(): Nothing = + unseenEntryRemoved.collect { entry -> + trackingJobsByEntry.remove(entry)?.let { + it.cancel() + logger.logStopTrackingLockscreenSeenDuration(entry) + } + } + + /** Start tracking new notifications when they are posted. */ + suspend fun trackNewUnseenNotifs(): Nothing = coroutineScope { + unseenEntryAdded.collect { entry -> + logger.logTrackingLockscreenSeenDuration(entry) + // If this is an update, reset the tracking. + trackingJobsByEntry[entry]?.let { + it.cancel() + logger.logResetSeenOnLockscreen(entry) } + trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) } } - // Suspend until the first emission - .first() + } + + // Start tracking for all notifications that are currently unseen. + logger.logTrackingLockscreenSeenDuration(unseenNotifications) + unseenNotifications.forEach { entry -> + trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) } + } + + launch { trackNewUnseenNotifs() } + launch { stopTrackingRemovedNotifs() } } - // Track "unseen" notifications, marking them as seen when either shade is expanded or the + // Track "seen" notifications, marking them as such when either shade is expanded or the // notification becomes heads up. - private suspend fun trackUnseenNotifications() { + private suspend fun trackSeenNotificationsWhileUnlocked() { coroutineScope { launch { clearUnseenNotificationsWhenShadeIsExpanded() } launch { markHeadsUpNotificationsAsSeen() } @@ -212,7 +260,7 @@ constructor( } } - private suspend fun invalidateWhenUnseenSettingChanges() { + private suspend fun trackUnseenFilterSettingChanges() { secureSettings // emit whenever the setting has changed .observerFlow( @@ -228,17 +276,23 @@ constructor( UserHandle.USER_CURRENT, ) == 1 } + // don't emit anything if nothing has changed + .distinctUntilChanged() // perform lookups on the bg thread pool .flowOn(bgDispatcher) // only track the most recent emission, if events are happening faster than they can be // consumed .conflate() - // update local field and invalidate if necessary - .collect { setting -> + .collectLatest { setting -> + // update local field and invalidate if necessary if (setting != unseenFilterEnabled) { unseenFilterEnabled = setting unseenNotifFilter.invalidateList("unseen setting changed") } + // if the setting is enabled, then start tracking and filtering unseen notifications + if (setting) { + trackSeenNotifications() + } } } @@ -250,6 +304,7 @@ constructor( ) { logger.logUnseenAdded(entry.key) unseenNotifications.add(entry) + unseenEntryAdded.tryEmit(entry) } } @@ -259,12 +314,14 @@ constructor( ) { logger.logUnseenUpdated(entry.key) unseenNotifications.add(entry) + unseenEntryAdded.tryEmit(entry) } } override fun onEntryRemoved(entry: NotificationEntry, reason: Int) { if (unseenNotifications.remove(entry)) { logger.logUnseenRemoved(entry.key) + unseenEntryRemoved.tryEmit(entry) } } } @@ -347,6 +404,3 @@ constructor( private val SEEN_TIMEOUT = 5.seconds } } - -private val TransitionStep.isScreenTurningOff: Boolean - get() = transitionState == TransitionState.STARTED && to != KeyguardState.GONE diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorLogger.kt index 4c33524346eb..788659eb3ccc 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorLogger.kt @@ -19,6 +19,7 @@ package com.android.systemui.statusbar.notification.collection.coordinator import com.android.systemui.log.LogBuffer import com.android.systemui.log.core.LogLevel import com.android.systemui.log.dagger.UnseenNotificationLog +import com.android.systemui.statusbar.notification.collection.NotificationEntry import javax.inject.Inject private const val TAG = "KeyguardCoordinator" @@ -28,11 +29,14 @@ class KeyguardCoordinatorLogger constructor( @UnseenNotificationLog private val buffer: LogBuffer, ) { - fun logSeenOnLockscreen() = + fun logSeenOnLockscreen(entry: NotificationEntry) = buffer.log( TAG, LogLevel.DEBUG, - "Notifications on lockscreen will be marked as seen when unlocked." + messageInitializer = { str1 = entry.key }, + messagePrinter = { + "Notification [$str1] on lockscreen will be marked as seen when unlocked." + }, ) fun logTrackingUnseen(trackingUnseen: Boolean) = @@ -43,11 +47,21 @@ constructor( messagePrinter = { "${if (bool1) "Start" else "Stop"} tracking unseen notifications." }, ) - fun logAllMarkedSeenOnUnlock() = + fun logAllMarkedSeenOnUnlock( + seenCount: Int, + remainingUnseenCount: Int, + ) = buffer.log( TAG, LogLevel.DEBUG, - "Notifications have been marked as seen now that device is unlocked." + messageInitializer = { + int1 = seenCount + int2 = remainingUnseenCount + }, + messagePrinter = { + "$int1 Notifications have been marked as seen now that device is unlocked. " + + "$int2 notifications remain unseen." + }, ) fun logShadeExpanded() = @@ -96,4 +110,60 @@ constructor( messageInitializer = { str1 = key }, messagePrinter = { "Unseen notif has become heads up: $str1" }, ) + + fun logTrackingLockscreenSeenDuration(unseenNotifications: Set<NotificationEntry>) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { + str1 = unseenNotifications.joinToString { it.key } + int1 = unseenNotifications.size + }, + messagePrinter = { + "Tracking $int1 unseen notifications for lockscreen seen duration threshold: $str1" + }, + ) + } + + fun logTrackingLockscreenSeenDuration(entry: NotificationEntry) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = entry.key }, + messagePrinter = { + "Tracking new notification for lockscreen seen duration threshold: $str1" + }, + ) + } + + fun logStopTrackingLockscreenSeenDuration(entry: NotificationEntry) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = entry.key }, + messagePrinter = { + "Stop tracking removed notification for lockscreen seen duration threshold: $str1" + }, + ) + } + + fun logResetSeenOnLockscreen(entry: NotificationEntry) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = entry.key }, + messagePrinter = { + "Reset tracking updated notification for lockscreen seen duration threshold: $str1" + }, + ) + } + + fun logRemoveSeenOnLockscreen(entry: NotificationEntry) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = entry.key }, + messagePrinter = { "Notification marked as seen on lockscreen removed: $str1" }, + ) + } } 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 2fbe87158eba..ea70e9e44c66 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 @@ -32,7 +32,6 @@ import com.android.systemui.keyguard.shared.model.TransitionState import com.android.systemui.keyguard.shared.model.TransitionStep 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 @@ -46,11 +45,14 @@ import com.android.systemui.statusbar.notification.interruption.KeyguardNotifica 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.any import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.withArgCaptor import com.android.systemui.util.settings.FakeSettings import com.google.common.truth.Truth.assertThat +import java.util.function.Consumer +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler @@ -62,9 +64,8 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.same import org.mockito.Mockito.anyString import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.never import org.mockito.Mockito.verify -import java.util.function.Consumer -import kotlin.time.Duration.Companion.seconds import org.mockito.Mockito.`when` as whenever @SmallTest @@ -75,7 +76,6 @@ class KeyguardCoordinatorTest : SysuiTestCase() { private val keyguardNotifVisibilityProvider: KeyguardNotificationVisibilityProvider = mock() private val keyguardRepository = FakeKeyguardRepository() private val keyguardTransitionRepository = FakeKeyguardTransitionRepository() - private val notifPipelineFlags: NotifPipelineFlags = mock() private val notifPipeline: NotifPipeline = mock() private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider = mock() private val statusBarStateController: StatusBarStateController = mock() @@ -136,13 +136,8 @@ class KeyguardCoordinatorTest : SysuiTestCase() { ) testScheduler.runCurrent() - // WHEN: The shade is expanded - whenever(statusBarStateController.isExpanded).thenReturn(true) - statusBarStateListener.onExpandedChanged(true) - testScheduler.runCurrent() - - // THEN: The notification is still treated as "unseen" and is not filtered out. - assertThat(unseenFilter.shouldFilterOut(fakeEntry, 0L)).isFalse() + // THEN: We are no longer listening for shade expansions + verify(statusBarStateController, never()).addCallback(any()) } } @@ -152,6 +147,10 @@ class KeyguardCoordinatorTest : SysuiTestCase() { keyguardRepository.setKeyguardShowing(false) whenever(statusBarStateController.isExpanded).thenReturn(false) runKeyguardCoordinatorTest { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) + // WHEN: A notification is posted val fakeEntry = NotificationEntryBuilder().build() collectionListener.onEntryAdded(fakeEntry) @@ -162,6 +161,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() { // WHEN: The keyguard is now showing keyguardRepository.setKeyguardShowing(true) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.AOD) + ) testScheduler.runCurrent() // THEN: The notification is recognized as "seen" and is filtered out. @@ -169,6 +171,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() { // WHEN: The keyguard goes away keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.AOD, to = KeyguardState.GONE) + ) testScheduler.runCurrent() // THEN: The notification is shown regardless @@ -182,9 +187,10 @@ class KeyguardCoordinatorTest : SysuiTestCase() { keyguardRepository.setKeyguardShowing(false) whenever(statusBarStateController.isExpanded).thenReturn(true) runKeyguardCoordinatorTest { - val fakeEntry = NotificationEntryBuilder() + val fakeEntry = + NotificationEntryBuilder() .setNotification(Notification.Builder(mContext, "id").setOngoing(true).build()) - .build() + .build() collectionListener.onEntryAdded(fakeEntry) // WHEN: The keyguard is now showing @@ -202,11 +208,13 @@ class KeyguardCoordinatorTest : SysuiTestCase() { keyguardRepository.setKeyguardShowing(false) whenever(statusBarStateController.isExpanded).thenReturn(true) runKeyguardCoordinatorTest { - val fakeEntry = NotificationEntryBuilder().build().apply { - row = mock<ExpandableNotificationRow>().apply { - whenever(isMediaRow).thenReturn(true) + val fakeEntry = + NotificationEntryBuilder().build().apply { + row = + mock<ExpandableNotificationRow>().apply { + whenever(isMediaRow).thenReturn(true) + } } - } collectionListener.onEntryAdded(fakeEntry) // WHEN: The keyguard is now showing @@ -299,14 +307,12 @@ class KeyguardCoordinatorTest : SysuiTestCase() { runKeyguardCoordinatorTest { // WHEN: A new notification is posted val fakeSummary = NotificationEntryBuilder().build() - val fakeChild = NotificationEntryBuilder() + val fakeChild = + NotificationEntryBuilder() .setGroup(context, "group") .setGroupSummary(context, false) .build() - GroupEntryBuilder() - .setSummary(fakeSummary) - .addChild(fakeChild) - .build() + GroupEntryBuilder().setSummary(fakeSummary).addChild(fakeChild).build() collectionListener.onEntryAdded(fakeSummary) collectionListener.onEntryAdded(fakeChild) @@ -331,6 +337,10 @@ class KeyguardCoordinatorTest : SysuiTestCase() { runKeyguardCoordinatorTest { val fakeEntry = NotificationEntryBuilder().build() collectionListener.onEntryAdded(fakeEntry) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.AOD, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() // WHEN: five seconds have passed testScheduler.advanceTimeBy(5.seconds) @@ -338,10 +348,16 @@ class KeyguardCoordinatorTest : SysuiTestCase() { // WHEN: Keyguard is no longer showing keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) testScheduler.runCurrent() // WHEN: Keyguard is shown again keyguardRepository.setKeyguardShowing(true) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.AOD) + ) testScheduler.runCurrent() // THEN: The notification is now recognized as "seen" and is filtered out. @@ -354,11 +370,17 @@ class KeyguardCoordinatorTest : SysuiTestCase() { // GIVEN: Keyguard is showing, unseen notification is present keyguardRepository.setKeyguardShowing(true) runKeyguardCoordinatorTest { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) val fakeEntry = NotificationEntryBuilder().build() collectionListener.onEntryAdded(fakeEntry) // WHEN: Keyguard is no longer showing keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) // WHEN: Keyguard is shown again keyguardRepository.setKeyguardShowing(true) @@ -369,14 +391,212 @@ class KeyguardCoordinatorTest : SysuiTestCase() { } } + @Test + fun unseenNotificationIsNotMarkedAsSeenIfNotOnKeyguardLongEnough() { + // GIVEN: Keyguard is showing, not dozing, unseen notification is present + keyguardRepository.setKeyguardShowing(true) + keyguardRepository.setIsDozing(false) + runKeyguardCoordinatorTest { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + val firstEntry = NotificationEntryBuilder().setId(1).build() + collectionListener.onEntryAdded(firstEntry) + testScheduler.runCurrent() + + // WHEN: one second has passed + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + + // WHEN: another unseen notification is posted + val secondEntry = NotificationEntryBuilder().setId(2).build() + collectionListener.onEntryAdded(secondEntry) + testScheduler.runCurrent() + + // WHEN: four more seconds have passed + testScheduler.advanceTimeBy(4.seconds) + testScheduler.runCurrent() + + // WHEN: the keyguard is no longer showing + keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) + testScheduler.runCurrent() + + // WHEN: Keyguard is shown again + keyguardRepository.setKeyguardShowing(true) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // THEN: The first notification is considered seen and is filtered out. + assertThat(unseenFilter.shouldFilterOut(firstEntry, 0L)).isTrue() + + // THEN: The second notification is still considered unseen and is not filtered out + assertThat(unseenFilter.shouldFilterOut(secondEntry, 0L)).isFalse() + } + } + + @Test + fun unseenNotificationOnKeyguardNotMarkedAsSeenIfRemovedAfterThreshold() { + // GIVEN: Keyguard is showing, not dozing + keyguardRepository.setKeyguardShowing(true) + keyguardRepository.setIsDozing(false) + runKeyguardCoordinatorTest { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // WHEN: a new notification is posted + val entry = NotificationEntryBuilder().setId(1).build() + collectionListener.onEntryAdded(entry) + testScheduler.runCurrent() + + // WHEN: five more seconds have passed + testScheduler.advanceTimeBy(5.seconds) + testScheduler.runCurrent() + + // WHEN: the notification is removed + collectionListener.onEntryRemoved(entry, 0) + testScheduler.runCurrent() + + // WHEN: the notification is re-posted + collectionListener.onEntryAdded(entry) + testScheduler.runCurrent() + + // WHEN: one more second has passed + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + + // WHEN: the keyguard is no longer showing + keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) + testScheduler.runCurrent() + + // WHEN: Keyguard is shown again + keyguardRepository.setKeyguardShowing(true) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // THEN: The notification is considered unseen and is not filtered out. + assertThat(unseenFilter.shouldFilterOut(entry, 0L)).isFalse() + } + } + + @Test + fun unseenNotificationOnKeyguardNotMarkedAsSeenIfRemovedBeforeThreshold() { + // GIVEN: Keyguard is showing, not dozing + keyguardRepository.setKeyguardShowing(true) + keyguardRepository.setIsDozing(false) + runKeyguardCoordinatorTest { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // WHEN: a new notification is posted + val entry = NotificationEntryBuilder().setId(1).build() + collectionListener.onEntryAdded(entry) + testScheduler.runCurrent() + + // WHEN: one second has passed + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + + // WHEN: the notification is removed + collectionListener.onEntryRemoved(entry, 0) + testScheduler.runCurrent() + + // WHEN: the notification is re-posted + collectionListener.onEntryAdded(entry) + testScheduler.runCurrent() + + // WHEN: one more second has passed + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + + // WHEN: the keyguard is no longer showing + keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) + testScheduler.runCurrent() + + // WHEN: Keyguard is shown again + keyguardRepository.setKeyguardShowing(true) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // THEN: The notification is considered unseen and is not filtered out. + assertThat(unseenFilter.shouldFilterOut(entry, 0L)).isFalse() + } + } + + @Test + fun unseenNotificationOnKeyguardNotMarkedAsSeenIfUpdatedBeforeThreshold() { + // GIVEN: Keyguard is showing, not dozing + keyguardRepository.setKeyguardShowing(true) + keyguardRepository.setIsDozing(false) + runKeyguardCoordinatorTest { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // WHEN: a new notification is posted + val entry = NotificationEntryBuilder().setId(1).build() + collectionListener.onEntryAdded(entry) + testScheduler.runCurrent() + + // WHEN: one second has passed + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + + // WHEN: the notification is updated + collectionListener.onEntryUpdated(entry) + testScheduler.runCurrent() + + // WHEN: four more seconds have passed + testScheduler.advanceTimeBy(4.seconds) + testScheduler.runCurrent() + + // WHEN: the keyguard is no longer showing + keyguardRepository.setKeyguardShowing(false) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GONE) + ) + testScheduler.runCurrent() + + // WHEN: Keyguard is shown again + keyguardRepository.setKeyguardShowing(true) + keyguardTransitionRepository.sendTransitionStep( + TransitionStep(from = KeyguardState.GONE, to = KeyguardState.LOCKSCREEN) + ) + testScheduler.runCurrent() + + // THEN: The notification is considered unseen and is not filtered out. + assertThat(unseenFilter.shouldFilterOut(entry, 0L)).isFalse() + } + } + private fun runKeyguardCoordinatorTest( testBlock: suspend KeyguardCoordinatorTestScope.() -> Unit ) { val testDispatcher = UnconfinedTestDispatcher() val testScope = TestScope(testDispatcher) - val fakeSettings = FakeSettings().apply { - putInt(Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 1) - } + val fakeSettings = + FakeSettings().apply { + putInt(Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 1) + } val seenNotificationsProvider = SeenNotificationsProviderImpl() val keyguardCoordinator = KeyguardCoordinator( @@ -387,7 +607,6 @@ class KeyguardCoordinatorTest : SysuiTestCase() { keyguardRepository, keyguardTransitionRepository, mock<KeyguardCoordinatorLogger>(), - notifPipelineFlags, testScope.backgroundScope, sectionHeaderVisibilityProvider, fakeSettings, @@ -397,11 +616,12 @@ class KeyguardCoordinatorTest : SysuiTestCase() { keyguardCoordinator.attach(notifPipeline) testScope.runTest(dispatchTimeoutMs = 1.seconds.inWholeMilliseconds) { KeyguardCoordinatorTestScope( - keyguardCoordinator, - testScope, - seenNotificationsProvider, - fakeSettings, - ).testBlock() + keyguardCoordinator, + testScope, + seenNotificationsProvider, + fakeSettings, + ) + .testBlock() } } @@ -414,10 +634,9 @@ class KeyguardCoordinatorTest : SysuiTestCase() { val testScheduler: TestCoroutineScheduler get() = scope.testScheduler - val onStateChangeListener: Consumer<String> = - withArgCaptor { - verify(keyguardNotifVisibilityProvider).addOnStateChangedListener(capture()) - } + val onStateChangeListener: Consumer<String> = withArgCaptor { + verify(keyguardNotifVisibilityProvider).addOnStateChangedListener(capture()) + } val unseenFilter: NotifFilter get() = keyguardCoordinator.unseenNotifFilter @@ -426,11 +645,11 @@ class KeyguardCoordinatorTest : SysuiTestCase() { verify(notifPipeline).addCollectionListener(capture()) } - val onHeadsUpChangedListener: OnHeadsUpChangedListener get() = - withArgCaptor { verify(headsUpManager).addListener(capture()) } + val onHeadsUpChangedListener: OnHeadsUpChangedListener + get() = withArgCaptor { verify(headsUpManager).addListener(capture()) } - val statusBarStateListener: StatusBarStateController.StateListener get() = - withArgCaptor { verify(statusBarStateController).addCallback(capture()) } + val statusBarStateListener: StatusBarStateController.StateListener + get() = withArgCaptor { verify(statusBarStateController).addCallback(capture()) } var showOnlyUnseenNotifsOnKeyguardSetting: Boolean get() = |