diff options
12 files changed, 668 insertions, 40 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorTest.kt new file mode 100644 index 000000000000..8f210098a727 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorTest.kt @@ -0,0 +1,124 @@ +/* + * 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.chips.notification.domain.interactor + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.kosmos.collectLastValue +import com.android.systemui.kosmos.runTest +import com.android.systemui.kosmos.useUnconfinedTestDispatcher +import com.android.systemui.statusbar.StatusBarIconView +import com.android.systemui.statusbar.chips.statusBarChipsLogger +import com.android.systemui.statusbar.notification.data.model.activeNotificationModel +import com.android.systemui.testKosmos +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SingleNotificationChipInteractorTest : SysuiTestCase() { + private val kosmos = testKosmos().useUnconfinedTestDispatcher() + val logger = kosmos.statusBarChipsLogger + + @Test + fun notificationChip_startsWithStartingModel() = + kosmos.runTest { + val icon = mock<StatusBarIconView>() + val startingNotif = activeNotificationModel(key = "notif1", statusBarChipIcon = icon) + + val underTest = SingleNotificationChipInteractor(startingNotif, logger) + + val latest by collectLastValue(underTest.notificationChip) + + assertThat(latest!!.key).isEqualTo("notif1") + assertThat(latest!!.statusBarChipIconView).isEqualTo(icon) + } + + @Test + fun notificationChip_updatesAfterSet() = + kosmos.runTest { + val originalIconView = mock<StatusBarIconView>() + val underTest = + SingleNotificationChipInteractor( + activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView), + logger, + ) + + val latest by collectLastValue(underTest.notificationChip) + + val newIconView = mock<StatusBarIconView>() + underTest.setNotification( + activeNotificationModel(key = "notif1", statusBarChipIcon = newIconView) + ) + + assertThat(latest!!.key).isEqualTo("notif1") + assertThat(latest!!.statusBarChipIconView).isEqualTo(newIconView) + } + + @Test + fun notificationChip_ignoresSetWithDifferentKey() = + kosmos.runTest { + val originalIconView = mock<StatusBarIconView>() + val underTest = + SingleNotificationChipInteractor( + activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView), + logger, + ) + + val latest by collectLastValue(underTest.notificationChip) + + val newIconView = mock<StatusBarIconView>() + underTest.setNotification( + activeNotificationModel(key = "other_notif", statusBarChipIcon = newIconView) + ) + + assertThat(latest!!.key).isEqualTo("notif1") + assertThat(latest!!.statusBarChipIconView).isEqualTo(originalIconView) + } + + @Test + fun notificationChip_missingStatusBarIconChipView_inConstructor_emitsNull() = + kosmos.runTest { + val underTest = + SingleNotificationChipInteractor( + activeNotificationModel(key = "notif1", statusBarChipIcon = null), + logger, + ) + + val latest by collectLastValue(underTest.notificationChip) + + assertThat(latest).isNull() + } + + @Test + fun notificationChip_missingStatusBarIconChipView_inSet_emitsNull() = + kosmos.runTest { + val startingNotif = activeNotificationModel(key = "notif1", statusBarChipIcon = mock()) + val underTest = SingleNotificationChipInteractor(startingNotif, logger) + val latest by collectLastValue(underTest.notificationChip) + assertThat(latest).isNotNull() + + underTest.setNotification( + activeNotificationModel(key = "notif1", statusBarChipIcon = null) + ) + + assertThat(latest).isNull() + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorTest.kt index 19ed6a57d2f0..702e101d2d39 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorTest.kt @@ -16,30 +16,277 @@ package com.android.systemui.statusbar.chips.notification.domain.interactor +import android.platform.test.annotations.DisableFlags import android.platform.test.annotations.EnableFlags import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.kosmos.testScope import com.android.systemui.kosmos.useUnconfinedTestDispatcher +import com.android.systemui.statusbar.StatusBarIconView import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips +import com.android.systemui.statusbar.notification.data.model.activeNotificationModel +import com.android.systemui.statusbar.notification.data.repository.ActiveNotificationsStore +import com.android.systemui.statusbar.notification.data.repository.activeNotificationListRepository +import com.android.systemui.statusbar.notification.shared.ActiveNotificationModel import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat import kotlin.test.Test import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith +import org.mockito.kotlin.mock @SmallTest @RunWith(AndroidJUnit4::class) -@EnableFlags(StatusBarNotifChips.FLAG_NAME) class StatusBarNotificationChipsInteractorTest : SysuiTestCase() { private val kosmos = testKosmos().useUnconfinedTestDispatcher() private val testScope = kosmos.testScope + private val activeNotificationListRepository = kosmos.activeNotificationListRepository - private val underTest = kosmos.statusBarNotificationChipsInteractor + private val underTest by lazy { + kosmos.statusBarNotificationChipsInteractor.also { it.start() } + } @Test + @DisableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_flagOff_noNotifs() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = mock<StatusBarIconView>(), + isPromoted = true, + ) + ) + ) + + assertThat(latest).isEmpty() + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_noNotifs_empty() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + setNotifs(emptyList()) + + assertThat(latest).isEmpty() + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_notifMissingStatusBarChipIconView_empty() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = null, + isPromoted = true, + ) + ) + ) + + assertThat(latest).isEmpty() + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_onePromotedNotif_statusBarIconViewMatches() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + val icon = mock<StatusBarIconView>() + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = icon, + isPromoted = true, + ) + ) + ) + + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(icon) + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_onlyForPromotedNotifs() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + val firstIcon = mock<StatusBarIconView>() + val secondIcon = mock<StatusBarIconView>() + setNotifs( + listOf( + activeNotificationModel( + key = "notif1", + statusBarChipIcon = firstIcon, + isPromoted = true, + ), + activeNotificationModel( + key = "notif2", + statusBarChipIcon = secondIcon, + isPromoted = true, + ), + activeNotificationModel( + key = "notif3", + statusBarChipIcon = mock<StatusBarIconView>(), + isPromoted = false, + ), + ) + ) + + assertThat(latest).hasSize(2) + assertThat(latest!![0].key).isEqualTo("notif1") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(firstIcon) + assertThat(latest!![1].key).isEqualTo("notif2") + assertThat(latest!![1].statusBarChipIconView).isEqualTo(secondIcon) + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_notifUpdatesGoThrough() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + val firstIcon = mock<StatusBarIconView>() + val secondIcon = mock<StatusBarIconView>() + val thirdIcon = mock<StatusBarIconView>() + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = firstIcon, + isPromoted = true, + ) + ) + ) + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(firstIcon) + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = secondIcon, + isPromoted = true, + ) + ) + ) + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(secondIcon) + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = thirdIcon, + isPromoted = true, + ) + ) + ) + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(thirdIcon) + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_promotedNotifDisappearsThenReappears() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = mock(), + isPromoted = true, + ) + ) + ) + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif") + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = mock(), + isPromoted = false, + ) + ) + ) + assertThat(latest).isEmpty() + + setNotifs( + listOf( + activeNotificationModel( + key = "notif", + statusBarChipIcon = mock(), + isPromoted = true, + ) + ) + ) + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif") + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) + fun notificationChips_notifChangesKey() = + testScope.runTest { + val latest by collectLastValue(underTest.notificationChips) + + val firstIcon = mock<StatusBarIconView>() + val secondIcon = mock<StatusBarIconView>() + setNotifs( + listOf( + activeNotificationModel( + key = "notif|uid1", + statusBarChipIcon = firstIcon, + isPromoted = true, + ) + ) + ) + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif|uid1") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(firstIcon) + + // WHEN a notification changes UID, which is a key change + setNotifs( + listOf( + activeNotificationModel( + key = "notif|uid2", + statusBarChipIcon = secondIcon, + isPromoted = true, + ) + ) + ) + + // THEN we correctly update + assertThat(latest).hasSize(1) + assertThat(latest!![0].key).isEqualTo("notif|uid2") + assertThat(latest!![0].statusBarChipIconView).isEqualTo(secondIcon) + } + + @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) fun onPromotedNotificationChipTapped_emitsKeys() = testScope.runTest { val latest by collectValues(underTest.promotedNotificationChipTapEvent) @@ -56,6 +303,7 @@ class StatusBarNotificationChipsInteractorTest : SysuiTestCase() { } @Test + @EnableFlags(StatusBarNotifChips.FLAG_NAME) fun onPromotedNotificationChipTapped_sameKeyTwice_emitsTwice() = testScope.runTest { val latest by collectValues(underTest.promotedNotificationChipTapEvent) @@ -67,4 +315,11 @@ class StatusBarNotificationChipsInteractorTest : SysuiTestCase() { assertThat(latest[0]).isEqualTo("fakeKey") assertThat(latest[1]).isEqualTo("fakeKey") } + + private fun setNotifs(notifs: List<ActiveNotificationModel>) { + activeNotificationListRepository.activeNotifications.value = + ActiveNotificationsStore.Builder() + .apply { notifs.forEach { addIndividualNotif(it) } } + .build() + } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt index 1b4132910555..16376c5b3850 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt @@ -22,7 +22,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue -import com.android.systemui.kosmos.testScope +import com.android.systemui.kosmos.collectLastValue +import com.android.systemui.kosmos.runTest +import com.android.systemui.kosmos.useUnconfinedTestDispatcher import com.android.systemui.statusbar.StatusBarIconView import com.android.systemui.statusbar.chips.notification.domain.interactor.statusBarNotificationChipsInteractor import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips @@ -34,26 +36,28 @@ import com.android.systemui.statusbar.notification.shared.ActiveNotificationMode import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat import kotlin.test.Test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @SmallTest @RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) @EnableFlags(StatusBarNotifChips.FLAG_NAME) class NotifChipsViewModelTest : SysuiTestCase() { - private val kosmos = testKosmos() - private val testScope = kosmos.testScope + private val kosmos = testKosmos().useUnconfinedTestDispatcher() private val activeNotificationListRepository = kosmos.activeNotificationListRepository - private val underTest = kosmos.notifChipsViewModel + private val underTest by lazy { kosmos.notifChipsViewModel } + + @Before + fun setUp() { + kosmos.statusBarNotificationChipsInteractor.start() + } @Test fun chips_noNotifs_empty() = - testScope.runTest { + kosmos.runTest { val latest by collectLastValue(underTest.chips) setNotifs(emptyList()) @@ -63,7 +67,7 @@ class NotifChipsViewModelTest : SysuiTestCase() { @Test fun chips_notifMissingStatusBarChipIconView_empty() = - testScope.runTest { + kosmos.runTest { val latest by collectLastValue(underTest.chips) setNotifs( @@ -81,7 +85,7 @@ class NotifChipsViewModelTest : SysuiTestCase() { @Test fun chips_onePromotedNotif_statusBarIconViewMatches() = - testScope.runTest { + kosmos.runTest { val latest by collectLastValue(underTest.chips) val icon = mock<StatusBarIconView>() @@ -103,7 +107,7 @@ class NotifChipsViewModelTest : SysuiTestCase() { @Test fun chips_onlyForPromotedNotifs() = - testScope.runTest { + kosmos.runTest { val latest by collectLastValue(underTest.chips) val firstIcon = mock<StatusBarIconView>() @@ -135,7 +139,7 @@ class NotifChipsViewModelTest : SysuiTestCase() { @Test fun chips_clickingChipNotifiesInteractor() = - testScope.runTest { + kosmos.runTest { val latest by collectLastValue(underTest.chips) val latestChipTap by collectLastValue( @@ -163,7 +167,6 @@ class NotifChipsViewModelTest : SysuiTestCase() { ActiveNotificationsStore.Builder() .apply { notifs.forEach { addIndividualNotif(it) } } .build() - testScope.runCurrent() } companion object { diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsWithNotifsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsWithNotifsViewModelTest.kt index 25d5ce50e03f..eb0978eff24b 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsWithNotifsViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/OngoingActivityChipsWithNotifsViewModelTest.kt @@ -27,6 +27,7 @@ import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope +import com.android.systemui.kosmos.useUnconfinedTestDispatcher import com.android.systemui.mediaprojection.data.model.MediaProjectionState import com.android.systemui.mediaprojection.data.repository.fakeMediaProjectionRepository import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager.Companion.createTask @@ -38,6 +39,7 @@ import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.Me import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractorTest.Companion.setUpPackageManagerForMediaProjection import com.android.systemui.statusbar.chips.notification.demo.ui.viewmodel.DemoNotifChipViewModelTest.Companion.addDemoNotifChip import com.android.systemui.statusbar.chips.notification.demo.ui.viewmodel.demoNotifChipViewModel +import com.android.systemui.statusbar.chips.notification.domain.interactor.statusBarNotificationChipsInteractor import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips import com.android.systemui.statusbar.chips.notification.ui.viewmodel.NotifChipsViewModelTest.Companion.assertIsNotifChip import com.android.systemui.statusbar.chips.ui.model.MultipleOngoingActivityChipsModel @@ -67,6 +69,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -79,7 +82,7 @@ import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) @EnableFlags(StatusBarNotifChips.FLAG_NAME) class OngoingActivityChipsWithNotifsViewModelTest : SysuiTestCase() { - private val kosmos = testKosmos() + private val kosmos = testKosmos().useUnconfinedTestDispatcher() private val testScope = kosmos.testScope private val systemClock = kosmos.fakeSystemClock private val commandRegistry = kosmos.commandRegistry @@ -103,12 +106,13 @@ class OngoingActivityChipsWithNotifsViewModelTest : SysuiTestCase() { .thenReturn(chipBackgroundView) } - private val underTest = kosmos.ongoingActivityChipsViewModel + private val underTest by lazy { kosmos.ongoingActivityChipsViewModel } @Before fun setUp() { setUpPackageManagerForMediaProjection(kosmos) kosmos.demoNotifChipViewModel.start() + kosmos.statusBarNotificationChipsInteractor.start() val icon = BitmapDrawable( context.resources, @@ -616,6 +620,7 @@ class OngoingActivityChipsWithNotifsViewModelTest : SysuiTestCase() { } @Test + @Ignore("b/364653005") // We'll need to re-do the animation story when we implement RON chips fun primaryChip_screenRecordStoppedViaDialog_chipHiddenWithoutAnimation() = testScope.runTest { screenRecordState.value = ScreenRecordModel.Recording diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorTest.kt index 2c488e3a7242..86a51912cc4f 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorTest.kt @@ -84,7 +84,9 @@ import org.mockito.MockitoAnnotations class HeadsUpCoordinatorTest : SysuiTestCase() { private val kosmos = testKosmos() private val testScope = kosmos.testScope - private val statusBarNotificationChipsInteractor = kosmos.statusBarNotificationChipsInteractor + private val statusBarNotificationChipsInteractor by lazy { + kosmos.statusBarNotificationChipsInteractor + } private val notifCollection = kosmos.mockNotifCollection private lateinit var coordinator: HeadsUpCoordinator diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/StatusBarChipsModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/StatusBarChipsModule.kt index 8ce0dbf8e171..6db610bbc3a6 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/StatusBarChipsModule.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/StatusBarChipsModule.kt @@ -21,7 +21,10 @@ import com.android.systemui.dagger.SysUISingleton import com.android.systemui.log.LogBuffer import com.android.systemui.log.LogBufferFactory import com.android.systemui.statusbar.chips.notification.demo.ui.viewmodel.DemoNotifChipViewModel +import com.android.systemui.statusbar.chips.notification.domain.interactor.StatusBarNotificationChipsInteractor +import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips import dagger.Binds +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.multibindings.ClassKey @@ -41,5 +44,19 @@ abstract class StatusBarChipsModule { fun provideChipsLogBuffer(factory: LogBufferFactory): LogBuffer { return factory.create("StatusBarChips", 200) } + + @Provides + @SysUISingleton + @IntoMap + @ClassKey(StatusBarNotificationChipsInteractor::class) + fun statusBarNotificationChipsInteractorAsCoreStartable( + interactorLazy: Lazy<StatusBarNotificationChipsInteractor> + ): CoreStartable { + return if (StatusBarNotifChips.isEnabled) { + interactorLazy.get() + } else { + CoreStartable.NOP + } + } } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractor.kt new file mode 100644 index 000000000000..b96359d4f33f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractor.kt @@ -0,0 +1,92 @@ +/* + * 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.chips.notification.domain.interactor + +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.Logger +import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad +import com.android.systemui.statusbar.chips.StatusBarChipsLog +import com.android.systemui.statusbar.chips.notification.domain.model.NotificationChipModel +import com.android.systemui.statusbar.notification.shared.ActiveNotificationModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * Interactor representing a single notification's status bar chip. + * + * [startingModel.key] dictates which notification this interactor corresponds to - all updates sent + * to this interactor via [setNotification] should only be for the notification with the same key. + * + * [StatusBarNotificationChipsInteractor] will collect all the individual instances of this + * interactor and send all the necessary information to the UI layer. + */ +class SingleNotificationChipInteractor +@AssistedInject +constructor( + @Assisted startingModel: ActiveNotificationModel, + @StatusBarChipsLog private val logBuffer: LogBuffer, +) { + private val key = startingModel.key + private val logger = Logger(logBuffer, "Notif".pad()) + // [StatusBarChipLogTag] recommends a max tag length of 20, so [extraLogTag] should NOT be the + // top-level tag. It should instead be provided as the first string in each log message. + private val extraLogTag = "SingleChipInteractor[key=$key]" + + private val _notificationModel = MutableStateFlow(startingModel) + + /** + * Sets the new notification info corresponding to this interactor. The key on [model] *must* + * match the key on the original [startingModel], otherwise the update won't be processed. + */ + fun setNotification(model: ActiveNotificationModel) { + if (model.key != this.key) { + logger.w({ "$str1: received model for different key $str2" }) { + str1 = extraLogTag + str2 = model.key + } + return + } + _notificationModel.value = model + } + + /** + * Emits this notification's status bar chip, or null if this notification shouldn't show a + * status bar chip. + */ + val notificationChip: Flow<NotificationChipModel?> = + _notificationModel.map { notif -> notif.toNotificationChipModel() } + + private fun ActiveNotificationModel.toNotificationChipModel(): NotificationChipModel? { + val statusBarChipIconView = this.statusBarChipIconView + if (statusBarChipIconView == null) { + logger.w({ "$str1: Can't show chip because status bar chip icon view is null" }) { + str1 = extraLogTag + } + return null + } + return NotificationChipModel(key, statusBarChipIconView) + } + + @AssistedFactory + fun interface Factory { + fun create(startingModel: ActiveNotificationModel): SingleNotificationChipInteractor + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractor.kt index 9e09671bc7bf..e8cb35b06999 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractor.kt @@ -17,16 +17,42 @@ package com.android.systemui.statusbar.chips.notification.domain.interactor import android.annotation.SuppressLint +import com.android.app.tracing.coroutines.launchTraced as launch +import com.android.systemui.CoreStartable import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.Logger +import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad +import com.android.systemui.statusbar.chips.StatusBarChipsLog +import com.android.systemui.statusbar.chips.notification.domain.model.NotificationChipModel import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips +import com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsInteractor +import com.android.systemui.util.kotlin.pairwise import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf /** An interactor for the notification chips shown in the status bar. */ @SysUISingleton -class StatusBarNotificationChipsInteractor @Inject constructor() { +@OptIn(ExperimentalCoroutinesApi::class) +class StatusBarNotificationChipsInteractor +@Inject +constructor( + @Background private val backgroundScope: CoroutineScope, + private val activeNotificationsInteractor: ActiveNotificationsInteractor, + private val singleNotificationChipInteractorFactory: SingleNotificationChipInteractor.Factory, + @StatusBarChipsLog private val logBuffer: LogBuffer, +) : CoreStartable { + private val logger = Logger(logBuffer, "AllNotifs".pad()) // Each chip tap is an individual event, *not* a state, which is why we're using SharedFlow not // StateFlow. There shouldn't be multiple updates per frame, which should avoid performance @@ -45,4 +71,79 @@ class StatusBarNotificationChipsInteractor @Inject constructor() { StatusBarNotifChips.assertInNewMode() _promotedNotificationChipTapEvent.emit(key) } + + /** + * A cache of interactors. Each currently-promoted notification should have a corresponding + * interactor in this map. + */ + private val promotedNotificationInteractorMap = + mutableMapOf<String, SingleNotificationChipInteractor>() + + /** + * A list of interactors. Each currently-promoted notification should have a corresponding + * interactor in this list. + */ + private val promotedNotificationInteractors = + MutableStateFlow<List<SingleNotificationChipInteractor>>(emptyList()) + + override fun start() { + if (!StatusBarNotifChips.isEnabled) { + return + } + + backgroundScope.launch("StatusBarNotificationChipsInteractor") { + activeNotificationsInteractor.promotedOngoingNotifications + .pairwise(initialValue = emptyList()) + .collect { (oldNotifs, currentNotifs) -> + val removedNotifs = oldNotifs.minus(currentNotifs.toSet()) + removedNotifs.forEach { removedNotif -> + val wasRemoved = promotedNotificationInteractorMap.remove(removedNotif.key) + if (wasRemoved == null) { + logger.w({ + "Attempted to remove $str1 from interactor map but it wasn't present" + }) { + str1 = removedNotif.key + } + } + } + currentNotifs.forEach { notif -> + val interactor = + promotedNotificationInteractorMap.computeIfAbsent(notif.key) { + singleNotificationChipInteractorFactory.create(notif) + } + interactor.setNotification(notif) + } + logger.d({ "Interactors: $str1" }) { + str1 = + promotedNotificationInteractorMap.keys.joinToString(separator = " /// ") + } + promotedNotificationInteractors.value = + promotedNotificationInteractorMap.values.toList() + } + } + } + + /** + * A flow modeling the notifications that should be shown as chips in the status bar. Emits an + * empty list if there are no notifications that should show a status bar chip. + */ + val notificationChips: Flow<List<NotificationChipModel>> = + if (StatusBarNotifChips.isEnabled) { + // For all our current interactors... + promotedNotificationInteractors.flatMapLatest { interactors -> + if (interactors.isNotEmpty()) { + // Combine each interactor's [notificationChip] flow... + val allNotificationChips: List<Flow<NotificationChipModel?>> = + interactors.map { interactor -> interactor.notificationChip } + combine(allNotificationChips) { + // ... and emit just the non-null chips + it.filterNotNull() + } + } else { + flowOf(emptyList()) + } + } + } else { + flowOf(emptyList()) + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/model/NotificationChipModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/model/NotificationChipModel.kt new file mode 100644 index 000000000000..5698ee6d1917 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/model/NotificationChipModel.kt @@ -0,0 +1,22 @@ +/* + * 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.chips.notification.domain.model + +import com.android.systemui.statusbar.StatusBarIconView + +/** Modeling all the data needed to render a status bar notification chip. */ +data class NotificationChipModel(val key: String, val statusBarChipIconView: StatusBarIconView) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt index 752674854e2d..9eff627c8714 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt @@ -20,11 +20,10 @@ import android.view.View import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.statusbar.chips.notification.domain.interactor.StatusBarNotificationChipsInteractor +import com.android.systemui.statusbar.chips.notification.domain.model.NotificationChipModel import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips import com.android.systemui.statusbar.chips.ui.model.ColorsModel import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel -import com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsInteractor -import com.android.systemui.statusbar.notification.shared.ActiveNotificationModel import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -37,7 +36,6 @@ class NotifChipsViewModel @Inject constructor( @Application private val applicationScope: CoroutineScope, - activeNotificationsInteractor: ActiveNotificationsInteractor, private val notifChipsInteractor: StatusBarNotificationChipsInteractor, ) { /** @@ -45,19 +43,14 @@ constructor( * no notifications that should show a status bar chip. */ val chips: Flow<List<OngoingActivityChipModel.Shown>> = - activeNotificationsInteractor.promotedOngoingNotifications.map { notifications -> - notifications.mapNotNull { it.toChipModel() } + notifChipsInteractor.notificationChips.map { notifications -> + notifications.map { it.toActivityChipModel() } } - /** - * Converts the notification to the [OngoingActivityChipModel] object. Returns null if the - * notification has invalid data such that it can't be displayed as a chip. - */ - private fun ActiveNotificationModel.toChipModel(): OngoingActivityChipModel.Shown? { + /** Converts the notification to the [OngoingActivityChipModel] object. */ + private fun NotificationChipModel.toActivityChipModel(): OngoingActivityChipModel.Shown { StatusBarNotifChips.assertInNewMode() - // TODO(b/364653005): Log error if there's no icon view. - val rawIcon = this.statusBarChipIconView ?: return null - val icon = OngoingActivityChipModel.ChipIcon.StatusBarView(rawIcon) + val icon = OngoingActivityChipModel.ChipIcon.StatusBarView(this.statusBarChipIconView) // TODO(b/364653005): Use the notification color if applicable. val colors = ColorsModel.Themed val onClickListener = @@ -65,7 +58,9 @@ constructor( // The notification pipeline needs everything to run on the main thread, so keep // this event on the main thread. applicationScope.launch { - notifChipsInteractor.onPromotedNotificationChipTapped(this@toChipModel.key) + notifChipsInteractor.onPromotedNotificationChipTapped( + this@toActivityChipModel.key + ) } } return OngoingActivityChipModel.Shown.IconOnly(icon, colors, onClickListener) diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorKosmos.kt index 74c7611a6392..eb3bab07b437 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorKosmos.kt @@ -17,6 +17,23 @@ package com.android.systemui.statusbar.chips.notification.domain.interactor import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope +import com.android.systemui.statusbar.chips.statusBarChipsLogger +import com.android.systemui.statusbar.notification.domain.interactor.activeNotificationsInteractor val Kosmos.statusBarNotificationChipsInteractor: StatusBarNotificationChipsInteractor by - Kosmos.Fixture { StatusBarNotificationChipsInteractor() } + Kosmos.Fixture { + StatusBarNotificationChipsInteractor( + testScope.backgroundScope, + activeNotificationsInteractor, + singleNotificationChipInteractorFactory, + logBuffer = statusBarChipsLogger, + ) + } + +val Kosmos.singleNotificationChipInteractorFactory: SingleNotificationChipInteractor.Factory by + Kosmos.Fixture { + SingleNotificationChipInteractor.Factory { startingModel -> + SingleNotificationChipInteractor(startingModel, logBuffer = statusBarChipsLogger) + } + } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelKosmos.kt index 68b28adb4b3a..4bcce8601d64 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelKosmos.kt @@ -19,13 +19,8 @@ package com.android.systemui.statusbar.chips.notification.ui.viewmodel import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.applicationCoroutineScope import com.android.systemui.statusbar.chips.notification.domain.interactor.statusBarNotificationChipsInteractor -import com.android.systemui.statusbar.notification.domain.interactor.activeNotificationsInteractor val Kosmos.notifChipsViewModel: NotifChipsViewModel by Kosmos.Fixture { - NotifChipsViewModel( - applicationCoroutineScope, - activeNotificationsInteractor, - statusBarNotificationChipsInteractor, - ) + NotifChipsViewModel(applicationCoroutineScope, statusBarNotificationChipsInteractor) } |