diff options
15 files changed, 749 insertions, 201 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt index fd1b21332973..419dbd07217d 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/domain/startable/SceneContainerStartableTest.kt @@ -175,12 +175,14 @@ class SceneContainerStartableTest : SysuiTestCase() { transitionStateFlow.value = ObservableTransitionState.Idle(Scenes.Gone) assertThat(isVisible).isFalse() - kosmos.headsUpNotificationRepository.activeHeadsUpRows.value = + kosmos.headsUpNotificationRepository.setNotifications( buildNotificationRows(isPinned = true) + ) assertThat(isVisible).isTrue() - kosmos.headsUpNotificationRepository.activeHeadsUpRows.value = + kosmos.headsUpNotificationRepository.setNotifications( buildNotificationRows(isPinned = false) + ) assertThat(isVisible).isFalse() } @@ -1642,8 +1644,8 @@ class SceneContainerStartableTest : SysuiTestCase() { return transitionStateFlow } - private fun buildNotificationRows(isPinned: Boolean = false): Set<HeadsUpRowRepository> = - setOf( + private fun buildNotificationRows(isPinned: Boolean = false): List<HeadsUpRowRepository> = + listOf( fakeHeadsUpRowRepository(key = "0", isPinned = isPinned), fakeHeadsUpRowRepository(key = "1", isPinned = isPinned), fakeHeadsUpRowRepository(key = "2", isPinned = isPinned), diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorTest.kt new file mode 100644 index 000000000000..8810ade1d851 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorTest.kt @@ -0,0 +1,510 @@ +/* + * 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. + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.systemui.statusbar.notification.collection.coordinator + +import android.app.Notification +import android.app.NotificationManager.IMPORTANCE_DEFAULT +import android.app.NotificationManager.IMPORTANCE_LOW +import android.os.UserHandle +import android.platform.test.annotations.EnableFlags +import android.provider.Settings +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.keyguard.shared.model.StatusBarState +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.kosmos.testScope +import com.android.systemui.plugins.statusbar.statusBarStateController +import com.android.systemui.shade.shadeTestUtil +import com.android.systemui.statusbar.SysuiStatusBarStateController +import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder +import com.android.systemui.statusbar.notification.collection.NotifPipeline +import com.android.systemui.statusbar.notification.collection.NotificationEntry +import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder +import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeTransformGroupsListener +import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter +import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner +import com.android.systemui.statusbar.notification.collection.modifyEntry +import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener +import com.android.systemui.statusbar.notification.data.repository.FakeHeadsUpRowRepository +import com.android.systemui.statusbar.notification.data.repository.activeNotificationListRepository +import com.android.systemui.statusbar.notification.shared.NotificationMinimalismPrototype +import com.android.systemui.statusbar.notification.stack.data.repository.headsUpNotificationRepository +import com.android.systemui.testKosmos +import com.android.systemui.util.settings.FakeSettings +import com.android.systemui.util.settings.fakeSettings +import com.google.common.truth.StringSubject +import com.google.common.truth.Truth.assertThat +import kotlin.time.Duration.Companion.seconds +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.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SmallTest +@RunWith(AndroidJUnit4::class) +@EnableFlags(NotificationMinimalismPrototype.FLAG_NAME) +class LockScreenMinimalismCoordinatorTest : SysuiTestCase() { + + private val kosmos = + testKosmos().apply { + testDispatcher = UnconfinedTestDispatcher() + statusBarStateController = + mock<SysuiStatusBarStateController>().also { mock -> + doAnswer { statusBarState.ordinal }.whenever(mock).state + } + fakeSettings.putInt(Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 1) + } + private val notifPipeline: NotifPipeline = mock() + private var statusBarState: StatusBarState = StatusBarState.KEYGUARD + + @Test + fun topUnseenSectioner() { + val solo = NotificationEntryBuilder().setTag("solo").build() + val child1 = NotificationEntryBuilder().setTag("child1").build() + val child2 = NotificationEntryBuilder().setTag("child2").build() + val parent = NotificationEntryBuilder().setTag("parent").build() + val group = GroupEntryBuilder().addChild(child1).addChild(child2).setSummary(parent).build() + + runCoordinatorTest { + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = solo.key + assertThat(topUnseenSectioner.isInSection(solo)).isTrue() + assertThat(topUnseenSectioner.isInSection(child1)).isFalse() + assertThat(topUnseenSectioner.isInSection(child2)).isFalse() + assertThat(topUnseenSectioner.isInSection(parent)).isFalse() + assertThat(topUnseenSectioner.isInSection(group)).isFalse() + + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = child1.key + assertThat(topUnseenSectioner.isInSection(solo)).isFalse() + assertThat(topUnseenSectioner.isInSection(child1)).isTrue() + assertThat(topUnseenSectioner.isInSection(child2)).isFalse() + assertThat(topUnseenSectioner.isInSection(parent)).isFalse() + assertThat(topUnseenSectioner.isInSection(group)).isTrue() + + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = parent.key + assertThat(topUnseenSectioner.isInSection(solo)).isFalse() + assertThat(topUnseenSectioner.isInSection(child1)).isFalse() + assertThat(topUnseenSectioner.isInSection(child2)).isFalse() + assertThat(topUnseenSectioner.isInSection(parent)).isTrue() + assertThat(topUnseenSectioner.isInSection(group)).isTrue() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = solo.key + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = null + assertThat(topUnseenSectioner.isInSection(solo)).isFalse() + assertThat(topUnseenSectioner.isInSection(child1)).isFalse() + assertThat(topUnseenSectioner.isInSection(child2)).isFalse() + assertThat(topUnseenSectioner.isInSection(parent)).isFalse() + assertThat(topUnseenSectioner.isInSection(group)).isFalse() + } + } + + @Test + fun topOngoingSectioner() { + val solo = NotificationEntryBuilder().setTag("solo").build() + val child1 = NotificationEntryBuilder().setTag("child1").build() + val child2 = NotificationEntryBuilder().setTag("child2").build() + val parent = NotificationEntryBuilder().setTag("parent").build() + val group = GroupEntryBuilder().addChild(child1).addChild(child2).setSummary(parent).build() + + runCoordinatorTest { + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = solo.key + assertThat(topOngoingSectioner.isInSection(solo)).isTrue() + assertThat(topOngoingSectioner.isInSection(child1)).isFalse() + assertThat(topOngoingSectioner.isInSection(child2)).isFalse() + assertThat(topOngoingSectioner.isInSection(parent)).isFalse() + assertThat(topOngoingSectioner.isInSection(group)).isFalse() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = child1.key + assertThat(topOngoingSectioner.isInSection(solo)).isFalse() + assertThat(topOngoingSectioner.isInSection(child1)).isTrue() + assertThat(topOngoingSectioner.isInSection(child2)).isFalse() + assertThat(topOngoingSectioner.isInSection(parent)).isFalse() + assertThat(topOngoingSectioner.isInSection(group)).isTrue() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = parent.key + assertThat(topOngoingSectioner.isInSection(solo)).isFalse() + assertThat(topOngoingSectioner.isInSection(child1)).isFalse() + assertThat(topOngoingSectioner.isInSection(child2)).isFalse() + assertThat(topOngoingSectioner.isInSection(parent)).isTrue() + assertThat(topOngoingSectioner.isInSection(group)).isTrue() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = null + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = solo.key + assertThat(topOngoingSectioner.isInSection(solo)).isFalse() + assertThat(topOngoingSectioner.isInSection(child1)).isFalse() + assertThat(topOngoingSectioner.isInSection(child2)).isFalse() + assertThat(topOngoingSectioner.isInSection(parent)).isFalse() + assertThat(topOngoingSectioner.isInSection(group)).isFalse() + } + } + + @Test + fun testPromoter() { + val child1 = NotificationEntryBuilder().setTag("child1").build() + val child2 = NotificationEntryBuilder().setTag("child2").build() + val child3 = NotificationEntryBuilder().setTag("child3").build() + val parent = NotificationEntryBuilder().setTag("parent").build() + GroupEntryBuilder() + .addChild(child1) + .addChild(child2) + .addChild(child3) + .setSummary(parent) + .build() + + runCoordinatorTest { + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = null + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = null + assertThat(promoter.shouldPromoteToTopLevel(child1)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(child2)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(child3)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(parent)).isFalse() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = child1.key + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = null + assertThat(promoter.shouldPromoteToTopLevel(child1)).isTrue() + assertThat(promoter.shouldPromoteToTopLevel(child2)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(child3)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(parent)).isFalse() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = null + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = child2.key + assertThat(promoter.shouldPromoteToTopLevel(child1)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(child2)) + .isEqualTo(NotificationMinimalismPrototype.ungroupTopUnseen) + assertThat(promoter.shouldPromoteToTopLevel(child3)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(parent)).isFalse() + + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value = child1.key + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value = child2.key + assertThat(promoter.shouldPromoteToTopLevel(child1)).isTrue() + assertThat(promoter.shouldPromoteToTopLevel(child2)) + .isEqualTo(NotificationMinimalismPrototype.ungroupTopUnseen) + assertThat(promoter.shouldPromoteToTopLevel(child3)).isFalse() + assertThat(promoter.shouldPromoteToTopLevel(parent)).isFalse() + } + } + + @Test + fun topOngoingIdentifier() { + val solo1 = defaultEntryBuilder().setTag("solo1").setRank(1).build() + val solo2 = defaultEntryBuilder().setTag("solo2").setRank(2).build() + val parent = defaultEntryBuilder().setTag("parent").setRank(3).build() + val child1 = defaultEntryBuilder().setTag("child1").setRank(4).build() + val child2 = defaultEntryBuilder().setTag("child2").setRank(5).build() + val group = GroupEntryBuilder().setSummary(parent).addChild(child1).addChild(child2).build() + val listEntryList = listOf(group, solo1, solo2) + + runCoordinatorTest { + // TEST: base case - no entries in the list + onBeforeTransformGroupsListener.onBeforeTransformGroups(emptyList()) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: none of these are unseen or ongoing yet, so don't pick them + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: when solo2 is the only one colorized, it gets picked up + solo2.setColorizedFgs(true) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(solo2.key) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: once solo1 is colorized, it takes priority for being ranked higher + solo1.setColorizedFgs(true) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(solo1.key) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: changing just the rank of solo1 causes it to pick up solo2 instead + solo1.modifyEntry { setRank(20) } + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(solo2.key) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: switching to SHADE disables the whole thing + statusBarState = StatusBarState.SHADE + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: switching back to KEYGUARD picks up the same entry again + statusBarState = StatusBarState.KEYGUARD + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(solo2.key) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: updating to not colorized revokes the top-ongoing status + solo2.setColorizedFgs(false) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(solo1.key) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: updating the importance to LOW revokes top-ongoing status + solo1.modifyEntry { setImportance(IMPORTANCE_LOW) } + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + } + } + + @Test + fun topUnseenIdentifier() { + val solo1 = defaultEntryBuilder().setTag("solo1").setRank(1).build() + val solo2 = defaultEntryBuilder().setTag("solo2").setRank(2).build() + val parent = defaultEntryBuilder().setTag("parent").setRank(4).build() + val child1 = defaultEntryBuilder().setTag("child1").setRank(5).build() + val child2 = defaultEntryBuilder().setTag("child2").setRank(6).build() + val group = GroupEntryBuilder().setSummary(parent).addChild(child1).addChild(child2).build() + val listEntryList = listOf(group, solo1, solo2) + val notificationEntryList = listOf(solo1, solo2, parent, child1, child2) + + runCoordinatorTest { + // All entries are added (and now unseen) + notificationEntryList.forEach { collectionListener.onEntryAdded(it) } + + // TEST: Filtered out entries are ignored + onBeforeTransformGroupsListener.onBeforeTransformGroups(emptyList()) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: top-ranked unseen child is selected (not the summary) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listOf(group)) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(child1.key) + + // TEST: top-ranked entry is picked + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo1.key) + + // TEST: if top-ranked unseen is colorized, fall back to #2 ranked unseen + solo1.setColorizedFgs(true) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(solo1.key) + assertThatTopUnseenKey().isEqualTo(solo2.key) + + // TEST: no more colorized entries + solo1.setColorizedFgs(false) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo1.key) + + // TEST: if the rank of solo1 is reduced, solo2 will be preferred + solo1.modifyEntry { setRank(3) } + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo2.key) + + // TEST: switching to SHADE state will disable the entire selector + statusBarState = StatusBarState.SHADE + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: switching back to KEYGUARD re-enables the selector + statusBarState = StatusBarState.KEYGUARD + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo2.key) + + // TEST: QS Expansion does not mark entries as seen + setShadeAndQsExpansionThenWait(0f, 1f) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo2.key) + + // TEST: Shade expansion does mark entries as seen + setShadeAndQsExpansionThenWait(1f, 0f) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: Entries updated while shade is expanded are NOT marked unseen + collectionListener.onEntryUpdated(solo1) + collectionListener.onEntryUpdated(solo2) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(null) + + // TEST: Entries updated after shade is collapsed ARE marked unseen + setShadeAndQsExpansionThenWait(0f, 0f) + collectionListener.onEntryUpdated(solo1) + collectionListener.onEntryUpdated(solo2) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo2.key) + + // TEST: low importance disqualifies the entry for top unseen + solo2.modifyEntry { setImportance(IMPORTANCE_LOW) } + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopOngoingKey().isEqualTo(null) + assertThatTopUnseenKey().isEqualTo(solo1.key) + } + } + + @Test + fun topUnseenIdentifier_headsUpMarksSeen() { + val solo1 = defaultEntryBuilder().setTag("solo1").setRank(1).build() + val solo2 = defaultEntryBuilder().setTag("solo2").setRank(2).build() + val listEntryList = listOf(solo1, solo2) + val notificationEntryList = listOf(solo1, solo2) + + val hunRepo1 = solo1.fakeHeadsUpRowRepository() + val hunRepo2 = solo2.fakeHeadsUpRowRepository() + + runCoordinatorTest { + // All entries are added (and now unseen) + notificationEntryList.forEach { collectionListener.onEntryAdded(it) } + + // TEST: top-ranked entry is picked + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopUnseenKey().isEqualTo(solo1.key) + + // TEST: heads up state and waiting isn't enough to be seen + kosmos.headsUpNotificationRepository.orderedHeadsUpRows.value = + listOf(hunRepo1, hunRepo2) + testScheduler.advanceTimeBy(1.seconds) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopUnseenKey().isEqualTo(solo1.key) + + // TEST: even being pinned doesn't take effect immediately + hunRepo1.isPinned.value = true + testScheduler.advanceTimeBy(0.5.seconds) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopUnseenKey().isEqualTo(solo1.key) + + // TEST: after being pinned a full second, solo1 is seen + testScheduler.advanceTimeBy(0.5.seconds) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopUnseenKey().isEqualTo(solo2.key) + + // TEST: repeat; being heads up and pinned for 1 second triggers seen + kosmos.headsUpNotificationRepository.orderedHeadsUpRows.value = listOf(hunRepo2) + hunRepo1.isPinned.value = false + hunRepo2.isPinned.value = true + testScheduler.advanceTimeBy(1.seconds) + onBeforeTransformGroupsListener.onBeforeTransformGroups(listEntryList) + assertThatTopUnseenKey().isEqualTo(null) + } + } + + private fun NotificationEntry.fakeHeadsUpRowRepository() = + FakeHeadsUpRowRepository(key = key, elementKey = Any()) + + private fun KeyguardCoordinatorTestScope.setShadeAndQsExpansionThenWait( + shadeExpansion: Float, + qsExpansion: Float + ) { + kosmos.shadeTestUtil.setShadeAndQsExpansion(shadeExpansion, qsExpansion) + // The coordinator waits a fraction of a second for the shade expansion to stick. + testScheduler.advanceTimeBy(1.seconds) + } + + private fun defaultEntryBuilder() = NotificationEntryBuilder().setImportance(IMPORTANCE_DEFAULT) + + private fun runCoordinatorTest(testBlock: suspend KeyguardCoordinatorTestScope.() -> Unit) { + kosmos.lockScreenMinimalismCoordinator.attach(notifPipeline) + kosmos.testScope.runTest(dispatchTimeoutMs = 1.seconds.inWholeMilliseconds) { + KeyguardCoordinatorTestScope( + kosmos.lockScreenMinimalismCoordinator, + kosmos.testScope, + kosmos.fakeSettings, + ) + .testBlock() + } + } + + private inner class KeyguardCoordinatorTestScope( + private val coordinator: LockScreenMinimalismCoordinator, + private val scope: TestScope, + private val fakeSettings: FakeSettings, + ) : CoroutineScope by scope { + fun assertThatTopOngoingKey(): StringSubject { + return assertThat( + kosmos.activeNotificationListRepository.topOngoingNotificationKey.value + ) + } + + fun assertThatTopUnseenKey(): StringSubject { + return assertThat( + kosmos.activeNotificationListRepository.topUnseenNotificationKey.value + ) + } + + val testScheduler: TestCoroutineScheduler + get() = scope.testScheduler + + val promoter: NotifPromoter + get() = coordinator.unseenNotifPromoter + + val topUnseenSectioner: NotifSectioner + get() = coordinator.topUnseenSectioner + + val topOngoingSectioner: NotifSectioner + get() = coordinator.topOngoingSectioner + + val onBeforeTransformGroupsListener: OnBeforeTransformGroupsListener = + argumentCaptor { verify(notifPipeline).addOnBeforeTransformGroupsListener(capture()) } + .lastValue + + val collectionListener: NotifCollectionListener = + argumentCaptor { verify(notifPipeline).addCollectionListener(capture()) }.lastValue + + var showOnlyUnseenNotifsOnKeyguardSetting: Boolean + get() = + fakeSettings.getIntForUser( + Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, + UserHandle.USER_CURRENT, + ) == 1 + set(value) { + fakeSettings.putIntForUser( + Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, + if (value) 1 else 2, + UserHandle.USER_CURRENT, + ) + } + } + + companion object { + + private fun NotificationEntry.setColorizedFgs(colorized: Boolean) { + sbn.notification.setColorizedFgs(colorized) + } + + private fun Notification.setColorizedFgs(colorized: Boolean) { + extras.putBoolean(Notification.EXTRA_COLORIZED, colorized) + flags = + if (colorized) { + flags or Notification.FLAG_FOREGROUND_SERVICE + } else { + flags and Notification.FLAG_FOREGROUND_SERVICE.inv() + } + } + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractorTest.kt index 8b4265f552fe..14134ccc34d0 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractorTest.kt @@ -33,7 +33,6 @@ import com.android.systemui.statusbar.notification.data.repository.FakeHeadsUpRo import com.android.systemui.statusbar.notification.data.repository.notificationsKeyguardViewStateRepository import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor import com.android.systemui.statusbar.notification.stack.data.repository.headsUpNotificationRepository -import com.android.systemui.statusbar.notification.stack.data.repository.setNotifications import com.android.systemui.statusbar.notification.stack.domain.interactor.headsUpNotificationInteractor import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt index ba7ddce10958..fb3689b1e2d3 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationListViewModelTest.kt @@ -43,7 +43,6 @@ import com.android.systemui.statusbar.notification.data.repository.setActiveNoti import com.android.systemui.statusbar.notification.footer.shared.FooterViewRefactor import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor import com.android.systemui.statusbar.notification.stack.data.repository.headsUpNotificationRepository -import com.android.systemui.statusbar.notification.stack.data.repository.setNotifications import com.android.systemui.statusbar.policy.data.repository.fakeUserSetupRepository import com.android.systemui.statusbar.policy.data.repository.zenModeRepository import com.android.systemui.statusbar.policy.fakeConfigurationController diff --git a/packages/SystemUI/src/com/android/systemui/flags/FlagDependencies.kt b/packages/SystemUI/src/com/android/systemui/flags/FlagDependencies.kt index af7ecf66d107..71c6fe096a56 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/FlagDependencies.kt +++ b/packages/SystemUI/src/com/android/systemui/flags/FlagDependencies.kt @@ -35,6 +35,8 @@ import com.android.systemui.statusbar.notification.footer.shared.FooterViewRefac import com.android.systemui.statusbar.notification.interruption.VisualInterruptionRefactor import com.android.systemui.statusbar.notification.shared.NotificationAvalancheSuppression import com.android.systemui.statusbar.notification.shared.NotificationIconContainerRefactor +import com.android.systemui.statusbar.notification.shared.NotificationMinimalismPrototype +import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor import com.android.systemui.statusbar.notification.shared.NotificationsLiveDataStoreRefactor import com.android.systemui.statusbar.notification.shared.PriorityPeopleSection import javax.inject.Inject @@ -53,6 +55,7 @@ class FlagDependencies @Inject constructor(featureFlags: FeatureFlagsClassic, ha FooterViewRefactor.token dependsOn NotificationIconContainerRefactor.token NotificationAvalancheSuppression.token dependsOn VisualInterruptionRefactor.token PriorityPeopleSection.token dependsOn SortBySectionTimeFlag.token + NotificationMinimalismPrototype.token dependsOn NotificationsHeadsUpRefactor.token // SceneContainer dependencies SceneContainerFlag.getFlagDependencies().forEach { (alpha, beta) -> alpha dependsOn beta } @@ -70,10 +73,13 @@ class FlagDependencies @Inject constructor(featureFlags: FeatureFlagsClassic, ha private inline val politeNotifications get() = FlagToken(FLAG_POLITE_NOTIFICATIONS, politeNotifications()) + private inline val crossAppPoliteNotifications get() = FlagToken(FLAG_CROSS_APP_POLITE_NOTIFICATIONS, crossAppPoliteNotifications()) + private inline val vibrateWhileUnlockedToken: FlagToken get() = FlagToken(FLAG_VIBRATE_WHILE_UNLOCKED, vibrateWhileUnlocked()) + private inline val communalHub get() = FlagToken(FLAG_COMMUNAL_HUB, communalHub()) } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinator.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinator.kt index 8c857f29f076..a6605f652ff3 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinator.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinator.kt @@ -25,13 +25,9 @@ import com.android.systemui.Dumpable import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dump.DumpManager -import com.android.systemui.keyguard.data.repository.KeyguardRepository -import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor -import com.android.systemui.keyguard.shared.model.KeyguardState import com.android.systemui.plugins.statusbar.StatusBarStateController -import com.android.systemui.scene.shared.model.Scenes +import com.android.systemui.shade.domain.interactor.ShadeInteractor import com.android.systemui.statusbar.StatusBarState -import com.android.systemui.statusbar.expansionChanges import com.android.systemui.statusbar.notification.collection.GroupEntry import com.android.systemui.statusbar.notification.collection.ListEntry import com.android.systemui.statusbar.notification.collection.NotifPipeline @@ -40,12 +36,11 @@ import com.android.systemui.statusbar.notification.collection.coordinator.dagger import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener +import com.android.systemui.statusbar.notification.domain.interactor.HeadsUpNotificationInteractor import com.android.systemui.statusbar.notification.domain.interactor.SeenNotificationsInteractor import com.android.systemui.statusbar.notification.shared.NotificationMinimalismPrototype import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_ONGOING import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_UNSEEN -import com.android.systemui.statusbar.policy.HeadsUpManager -import com.android.systemui.statusbar.policy.headsUpEvents import com.android.systemui.util.asIndenting import com.android.systemui.util.printCollection import com.android.systemui.util.settings.SecureSettings @@ -55,21 +50,17 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -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.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import kotlinx.coroutines.yield /** * If the setting is enabled, this will track seen notifications and ensure that they only show in @@ -84,19 +75,17 @@ class LockScreenMinimalismCoordinator constructor( @Background private val bgDispatcher: CoroutineDispatcher, private val dumpManager: DumpManager, - private val headsUpManager: HeadsUpManager, - private val keyguardRepository: KeyguardRepository, - private val keyguardTransitionInteractor: KeyguardTransitionInteractor, - private val logger: KeyguardCoordinatorLogger, + private val headsUpInteractor: HeadsUpNotificationInteractor, + private val logger: LockScreenMinimalismCoordinatorLogger, @Application private val scope: CoroutineScope, private val secureSettings: SecureSettings, private val seenNotificationsInteractor: SeenNotificationsInteractor, private val statusBarStateController: StatusBarStateController, + private val shadeInteractor: ShadeInteractor, ) : Coordinator, Dumpable { private val unseenNotifications = mutableSetOf<NotificationEntry>() - private val unseenEntryAdded = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) - private val unseenEntryRemoved = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) + private var isShadeVisible = false private var unseenFilterEnabled = false override fun attach(pipeline: NotifPipeline) { @@ -111,130 +100,6 @@ constructor( } private suspend fun trackSeenNotifications() { - // Whether or not keyguard is visible (or occluded). - @Suppress("DEPRECATION") - val isKeyguardPresentFlow: Flow<Boolean> = - keyguardTransitionInteractor - .transitionValue( - scene = Scenes.Gone, - stateWithoutSceneContainer = KeyguardState.GONE, - ) - .map { it == 0f } - .distinctUntilChanged() - .onEach { trackingUnseen -> logger.logTrackingUnseen(trackingUnseen) } - - // 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. - isKeyguardPresentFlow.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 { - // 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() - } - unseenNotifPromoter.invalidateList("keyguard no longer showing") - // Keyguard is gone, notifications can be immediately marked as seen when they - // become visible. - trackSeenNotificationsWhileUnlocked() - } - } - } - - /** - * 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) } - } - } - - // 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 "seen" notifications, marking them as such when either shade is expanded or the - // notification becomes heads up. - private suspend fun trackSeenNotificationsWhileUnlocked() { coroutineScope { launch { clearUnseenNotificationsWhenShadeIsExpanded() } launch { markHeadsUpNotificationsAsSeen() } @@ -242,27 +107,38 @@ constructor( } private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() { - statusBarStateController.expansionChanges.collectLatest { isExpanded -> + shadeInteractor.isShadeFullyExpanded.collectLatest { isExpanded -> // Give keyguard events time to propagate, in case this expansion is part of the // keyguard transition and not the user expanding the shade - yield() + delay(SHADE_VISIBLE_SEEN_TIMEOUT) + isShadeVisible = isExpanded if (isExpanded) { - logger.logShadeExpanded() + logger.logShadeVisible(unseenNotifications.size) unseenNotifications.clear() + // no need to invalidateList; filtering is inactive while shade is open + } else { + logger.logShadeHidden() } } } private suspend fun markHeadsUpNotificationsAsSeen() { - headsUpManager.allEntries - .filter { it.isRowPinned } - .forEach { unseenNotifications.remove(it) } - headsUpManager.headsUpEvents.collect { (entry, isHun) -> - if (isHun) { - logger.logUnseenHun(entry.key) - unseenNotifications.remove(entry) + headsUpInteractor.topHeadsUpRowIfPinned + .map { it?.let { headsUpInteractor.notificationKey(it) } } + .collectLatest { key -> + if (key == null) { + logger.logTopHeadsUpRow(key = null, wasUnseenWhenPinned = false) + } else { + val wasUnseenWhenPinned = unseenNotifications.any { it.key == key } + logger.logTopHeadsUpRow(key, wasUnseenWhenPinned) + if (wasUnseenWhenPinned) { + delay(HEADS_UP_SEEN_TIMEOUT) + val wasUnseenAfterDelay = unseenNotifications.removeIf { it.key == key } + logger.logHunHasBeenSeen(key, wasUnseenAfterDelay) + // no need to invalidateList; nothing should change until after heads up + } + } } - } } private fun unseenFeatureEnabled(): Flow<Boolean> { @@ -297,14 +173,15 @@ constructor( } private suspend fun trackUnseenFilterSettingChanges() { - unseenFeatureEnabled().collectLatest { setting -> + unseenFeatureEnabled().collectLatest { isSettingEnabled -> // update local field and invalidate if necessary - if (setting != unseenFilterEnabled) { - unseenFilterEnabled = setting + if (isSettingEnabled != unseenFilterEnabled) { + unseenFilterEnabled = isSettingEnabled unseenNotifPromoter.invalidateList("unseen setting changed") } // if the setting is enabled, then start tracking and filtering unseen notifications - if (setting) { + logger.logTrackingUnseen(isSettingEnabled) + if (isSettingEnabled) { trackSeenNotifications() } } @@ -313,29 +190,22 @@ constructor( private val collectionListener = object : NotifCollectionListener { override fun onEntryAdded(entry: NotificationEntry) { - if ( - keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded - ) { + if (!isShadeVisible) { logger.logUnseenAdded(entry.key) unseenNotifications.add(entry) - unseenEntryAdded.tryEmit(entry) } } override fun onEntryUpdated(entry: NotificationEntry) { - if ( - keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded - ) { + if (!isShadeVisible) { 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) } } } @@ -376,11 +246,12 @@ constructor( val unseenNotifPromoter = object : NotifPromoter(TAG) { override fun shouldPromoteToTopLevel(child: NotificationEntry): Boolean = - if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) false - else if (!NotificationMinimalismPrototype.ungroupTopUnseen) false - else - seenNotificationsInteractor.isTopOngoingNotification(child) || - seenNotificationsInteractor.isTopUnseenNotification(child) + when { + NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode() -> false + seenNotificationsInteractor.isTopOngoingNotification(child) -> true + !NotificationMinimalismPrototype.ungroupTopUnseen -> false + else -> seenNotificationsInteractor.isTopUnseenNotification(child) + } } val topOngoingSectioner = @@ -418,6 +289,7 @@ constructor( companion object { private const val TAG = "LockScreenMinimalismCoordinator" - private val SEEN_TIMEOUT = 5.seconds + private val SHADE_VISIBLE_SEEN_TIMEOUT = 0.25.seconds + private val HEADS_UP_SEEN_TIMEOUT = 0.75.seconds } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorLogger.kt new file mode 100644 index 000000000000..e44a77c30999 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorLogger.kt @@ -0,0 +1,99 @@ +/* + * 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.notification.collection.coordinator + +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.LogLevel +import com.android.systemui.log.dagger.UnseenNotificationLog +import javax.inject.Inject + +private const val TAG = "LockScreenMinimalismCoordinator" + +class LockScreenMinimalismCoordinatorLogger +@Inject +constructor( + @UnseenNotificationLog private val buffer: LogBuffer, +) { + + fun logTrackingUnseen(trackingUnseen: Boolean) = + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { bool1 = trackingUnseen }, + messagePrinter = { "${if (bool1) "Start" else "Stop"} tracking unseen notifications." }, + ) + + fun logShadeVisible(numUnseen: Int) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { int1 = numUnseen }, + messagePrinter = { "Shade expanded. Notifications marked as seen: $int1" } + ) + } + + fun logShadeHidden() { + buffer.log(TAG, LogLevel.DEBUG, "Shade no longer expanded.") + } + + fun logUnseenAdded(key: String) = + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = key }, + messagePrinter = { "Unseen notif added: $str1" }, + ) + + fun logUnseenUpdated(key: String) = + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = key }, + messagePrinter = { "Unseen notif updated: $str1" }, + ) + + fun logUnseenRemoved(key: String) = + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { str1 = key }, + messagePrinter = { "Unseen notif removed: $str1" }, + ) + + fun logHunHasBeenSeen(key: String, wasUnseen: Boolean) = + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { + str1 = key + bool1 = wasUnseen + }, + messagePrinter = { "Heads up notif has been seen: $str1 wasUnseen=$bool1" }, + ) + + fun logTopHeadsUpRow(key: String?, wasUnseenWhenPinned: Boolean) { + buffer.log( + TAG, + LogLevel.DEBUG, + messageInitializer = { + str1 = key + bool1 = wasUnseenWhenPinned + }, + messagePrinter = { "New notif is top heads up: $str1 wasUnseen=$bool1" }, + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractor.kt index bf44b9f3cf78..24b75d49ed16 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/domain/interactor/HeadsUpNotificationInteractor.kt @@ -30,6 +30,7 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -44,8 +45,17 @@ constructor( private val shadeInteractor: ShadeInteractor, ) { + /** The top-ranked heads up row, regardless of pinned state */ val topHeadsUpRow: Flow<HeadsUpRowKey?> = headsUpRepository.topHeadsUpRow + /** The top-ranked heads up row, if that row is pinned */ + val topHeadsUpRowIfPinned: Flow<HeadsUpRowKey?> = + headsUpRepository.topHeadsUpRow + .flatMapLatest { repository -> + repository?.isPinned?.map { pinned -> repository.takeIf { pinned } } ?: flowOf(null) + } + .distinctUntilChanged() + /** Set of currently pinned top-level heads up rows to be displayed. */ val pinnedHeadsUpRows: Flow<Set<HeadsUpRowKey>> by lazy { if (NotificationsHeadsUpRefactor.isUnexpectedlyInLegacyMode()) { @@ -89,10 +99,10 @@ constructor( flowOf(false) } else { combine(hasPinnedRows, headsUpRepository.isHeadsUpAnimatingAway) { - hasPinnedRows, - animatingAway -> - hasPinnedRows || animatingAway - } + hasPinnedRows, + animatingAway -> + hasPinnedRows || animatingAway + } } } @@ -127,6 +137,9 @@ constructor( fun elementKeyFor(key: HeadsUpRowKey) = (key as HeadsUpRowRepository).elementKey + /** Returns the Notification Key (the standard string) of this row. */ + fun notificationKey(key: HeadsUpRowKey): String = (key as HeadsUpRowRepository).key + fun setHeadsUpAnimatingAway(animatingAway: Boolean) { headsUpRepository.setHeadsUpAnimatingAway(animatingAway) } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationMinimalismPrototype.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationMinimalismPrototype.kt index 625eed4ed959..06f3db504aaf 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationMinimalismPrototype.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/shared/NotificationMinimalismPrototype.kt @@ -45,7 +45,7 @@ object NotificationMinimalismPrototype { else SystemProperties.getBoolean( "persist.notification_minimalism_prototype.ungroup_top_unseen", - true + false ) /** diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerWithCoroutinesTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerWithCoroutinesTest.kt index e1d92e780c2a..eed0e5a38021 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerWithCoroutinesTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerWithCoroutinesTest.kt @@ -34,7 +34,6 @@ import com.android.systemui.statusbar.StatusBarState.SHADE import com.android.systemui.statusbar.StatusBarState.SHADE_LOCKED import com.android.systemui.statusbar.notification.data.repository.FakeHeadsUpRowRepository import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor -import com.android.systemui.statusbar.notification.stack.data.repository.setNotifications import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/ui/viewmodel/KeyguardStatusBarViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/ui/viewmodel/KeyguardStatusBarViewModelTest.kt index cc2ef53c6cdb..12cfdcfa8df5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/ui/viewmodel/KeyguardStatusBarViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/ui/viewmodel/KeyguardStatusBarViewModelTest.kt @@ -35,7 +35,6 @@ import com.android.systemui.statusbar.domain.interactor.keyguardStatusBarInterac import com.android.systemui.statusbar.notification.data.repository.FakeHeadsUpRowRepository import com.android.systemui.statusbar.notification.shared.NotificationsHeadsUpRefactor import com.android.systemui.statusbar.notification.stack.data.repository.headsUpNotificationRepository -import com.android.systemui.statusbar.notification.stack.data.repository.setNotifications import com.android.systemui.statusbar.notification.stack.domain.interactor.headsUpNotificationInteractor import com.android.systemui.statusbar.policy.BatteryController import com.android.systemui.statusbar.policy.batteryController diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt index da956ec67696..8b4de2bcc26f 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt @@ -22,13 +22,12 @@ package com.android.systemui.statusbar.notification.collection * The [modifier] function will be passed an instance of a NotificationEntryBuilder. Any * modifications made to the builder will be applied to the [entry]. */ -inline fun modifyEntry( - entry: NotificationEntry, +inline fun NotificationEntry.modifyEntry( crossinline modifier: NotificationEntryBuilder.() -> Unit ) { - val builder = NotificationEntryBuilder(entry) + val builder = NotificationEntryBuilder(this) modifier(builder) - builder.apply(entry) + builder.apply(this) } fun getAttachState(entry: ListEntry): ListAttachState { diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorKosmos.kt new file mode 100644 index 000000000000..77d97bb7cbe9 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorKosmos.kt @@ -0,0 +1,42 @@ +/* + * 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.notification.collection.coordinator + +import com.android.systemui.dump.dumpManager +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.kosmos.testScope +import com.android.systemui.plugins.statusbar.statusBarStateController +import com.android.systemui.shade.domain.interactor.shadeInteractor +import com.android.systemui.statusbar.notification.domain.interactor.seenNotificationsInteractor +import com.android.systemui.statusbar.notification.stack.domain.interactor.headsUpNotificationInteractor +import com.android.systemui.util.settings.fakeSettings + +var Kosmos.lockScreenMinimalismCoordinator by + Kosmos.Fixture { + LockScreenMinimalismCoordinator( + bgDispatcher = testDispatcher, + dumpManager = dumpManager, + headsUpInteractor = headsUpNotificationInteractor, + logger = lockScreenMinimalismCoordinatorLogger, + scope = testScope.backgroundScope, + secureSettings = fakeSettings, + seenNotificationsInteractor = seenNotificationsInteractor, + statusBarStateController = statusBarStateController, + shadeInteractor = shadeInteractor, + ) + } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/data/repository/HeadsUpNotificationsRepositoryExt.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorLoggerKosmos.kt index 9be7dfe9a1a9..77aeb44e15eb 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/data/repository/HeadsUpNotificationsRepositoryExt.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/collection/coordinator/LockScreenMinimalismCoordinatorLoggerKosmos.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 The Android Open Source Project + * 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. @@ -14,14 +14,10 @@ * limitations under the License. */ -package com.android.systemui.statusbar.notification.stack.data.repository +package com.android.systemui.statusbar.notification.collection.coordinator -import com.android.systemui.statusbar.notification.data.repository.HeadsUpRowRepository +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.log.logcatLogBuffer -fun FakeHeadsUpNotificationRepository.setNotifications(notifications: List<HeadsUpRowRepository>) { - setNotifications(*notifications.toTypedArray()) -} - -fun FakeHeadsUpNotificationRepository.setNotifications(vararg notifications: HeadsUpRowRepository) { - this.activeHeadsUpRows.value = notifications.toSet() -} +val Kosmos.lockScreenMinimalismCoordinatorLogger by + Kosmos.Fixture { LockScreenMinimalismCoordinatorLogger(logcatLogBuffer()) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/data/repository/HeadsUpNotificationRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/data/repository/HeadsUpNotificationRepositoryKosmos.kt index 492e87bbcac4..7e8f1a9115ea 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/data/repository/HeadsUpNotificationRepositoryKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/notification/stack/data/repository/HeadsUpNotificationRepositoryKosmos.kt @@ -22,14 +22,19 @@ import com.android.systemui.statusbar.notification.data.repository.HeadsUpReposi import com.android.systemui.statusbar.notification.data.repository.HeadsUpRowRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map val Kosmos.headsUpNotificationRepository by Fixture { FakeHeadsUpNotificationRepository() } class FakeHeadsUpNotificationRepository : HeadsUpRepository { override val isHeadsUpAnimatingAway: MutableStateFlow<Boolean> = MutableStateFlow(false) - override val topHeadsUpRow: Flow<HeadsUpRowRepository?> = MutableStateFlow(null) - override val activeHeadsUpRows: MutableStateFlow<Set<HeadsUpRowRepository>> = - MutableStateFlow(emptySet()) + + val orderedHeadsUpRows = MutableStateFlow(emptyList<HeadsUpRowRepository>()) + override val topHeadsUpRow: Flow<HeadsUpRowRepository?> = + orderedHeadsUpRows.map { it.firstOrNull() }.distinctUntilChanged() + override val activeHeadsUpRows: Flow<Set<HeadsUpRowRepository>> = + orderedHeadsUpRows.map { it.toSet() }.distinctUntilChanged() override fun setHeadsUpAnimatingAway(animatingAway: Boolean) { isHeadsUpAnimatingAway.value = animatingAway @@ -38,4 +43,12 @@ class FakeHeadsUpNotificationRepository : HeadsUpRepository { override fun snooze() { // do nothing } + + fun setNotifications(notifications: List<HeadsUpRowRepository>) { + this.orderedHeadsUpRows.value = notifications.toList() + } + + fun setNotifications(vararg notifications: HeadsUpRowRepository) { + this.orderedHeadsUpRows.value = notifications.toList() + } } |