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

Commit 9f732a9e authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "[SB][Notifs] Show HUN when status bar notification chip is tapped." into main

parents 7068e2e7 534d55df
Loading
Loading
Loading
Loading
+70 −0
Original line number Diff line number Diff line
/*
 * 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 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.collectValues
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
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

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(StatusBarNotifChips.FLAG_NAME)
class StatusBarNotificationChipsInteractorTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val testScope = kosmos.testScope

    private val underTest = kosmos.statusBarNotificationChipsInteractor

    @Test
    fun onPromotedNotificationChipTapped_emitsKeys() =
        testScope.runTest {
            val latest by collectValues(underTest.promotedNotificationChipTapEvent)

            underTest.onPromotedNotificationChipTapped("fakeKey")

            assertThat(latest).hasSize(1)
            assertThat(latest[0]).isEqualTo("fakeKey")

            underTest.onPromotedNotificationChipTapped("fakeKey2")

            assertThat(latest).hasSize(2)
            assertThat(latest[1]).isEqualTo("fakeKey2")
        }

    @Test
    fun onPromotedNotificationChipTapped_sameKeyTwice_emitsTwice() =
        testScope.runTest {
            val latest by collectValues(underTest.promotedNotificationChipTapEvent)

            underTest.onPromotedNotificationChipTapped("fakeKey")
            underTest.onPromotedNotificationChipTapped("fakeKey")

            assertThat(latest).hasSize(2)
            assertThat(latest[0]).isEqualTo("fakeKey")
            assertThat(latest[1]).isEqualTo("fakeKey")
        }
}
+26 −0
Original line number Diff line number Diff line
@@ -17,12 +17,14 @@
package com.android.systemui.statusbar.chips.notification.ui.viewmodel

import android.platform.test.annotations.EnableFlags
import android.view.View
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.statusbar.StatusBarIconView
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.ui.model.OngoingActivityChipModel
import com.android.systemui.statusbar.notification.data.model.activeNotificationModel
@@ -102,6 +104,30 @@ class NotifChipsViewModelTest : SysuiTestCase() {
            assertIsNotifChip(latest!![1], secondIcon)
        }

    @Test
    fun chips_clickingChipNotifiesInteractor() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)
            val latestChipTap by
                collectLastValue(
                    kosmos.statusBarNotificationChipsInteractor.promotedNotificationChipTapEvent
                )

            setNotifs(
                listOf(
                    activeNotificationModel(
                        key = "clickTest",
                        statusBarChipIcon = mock<StatusBarIconView>(),
                    )
                )
            )
            val chip = latest!![0]

            chip.onClickListener!!.onClick(mock<View>())

            assertThat(latestChipTap).isEqualTo("clickTest")
        }

    private fun setNotifs(notifs: List<ActiveNotificationModel>) {
        activeNotificationListRepository.activeNotifications.value =
            ActiveNotificationsStore.Builder()
+119 −11
Original line number Diff line number Diff line
@@ -17,12 +17,18 @@ package com.android.systemui.statusbar.notification.collection.coordinator

import android.app.Notification.GROUP_ALERT_ALL
import android.app.Notification.GROUP_ALERT_SUMMARY
import android.platform.test.annotations.EnableFlags
import android.testing.TestableLooper.RunWithLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.kosmos.testScope
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.statusbar.NotificationRemoteInputManager
import com.android.systemui.statusbar.chips.notification.domain.interactor.statusBarNotificationChipsInteractor
import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
import com.android.systemui.statusbar.notification.HeadsUpManagerPhone
import com.android.systemui.statusbar.notification.NotifPipelineFlags
import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder
import com.android.systemui.statusbar.notification.collection.NotifPipeline
@@ -32,6 +38,8 @@ import com.android.systemui.statusbar.notification.collection.listbuilder.OnBefo
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.mockNotifCollection
import com.android.systemui.statusbar.notification.collection.notifCollection
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender.OnEndLifetimeExtensionCallback
@@ -43,9 +51,9 @@ import com.android.systemui.statusbar.notification.interruption.NotificationInte
import com.android.systemui.statusbar.notification.interruption.NotificationInterruptStateProviderWrapper.FullScreenIntentDecisionImpl
import com.android.systemui.statusbar.notification.interruption.VisualInterruptionDecisionProvider
import com.android.systemui.statusbar.notification.row.NotifBindPipeline.BindCallback
import com.android.systemui.statusbar.notification.HeadsUpManagerPhone
import com.android.systemui.statusbar.phone.NotificationGroupTestHelper
import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener
import com.android.systemui.testKosmos
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
@@ -54,6 +62,7 @@ import com.android.systemui.util.mockito.withArgCaptor
import com.android.systemui.util.time.FakeSystemClock
import java.util.ArrayList
import java.util.function.Consumer
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -73,6 +82,11 @@ import org.mockito.MockitoAnnotations
@RunWith(AndroidJUnit4::class)
@RunWithLooper
class HeadsUpCoordinatorTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val statusBarNotificationChipsInteractor = kosmos.statusBarNotificationChipsInteractor
    private val notifCollection = kosmos.mockNotifCollection

    private lateinit var coordinator: HeadsUpCoordinator

    // captured listeners and pluggables:
@@ -115,16 +129,19 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        helper = NotificationGroupTestHelper(mContext)
        coordinator =
            HeadsUpCoordinator(
                kosmos.applicationCoroutineScope,
                logger,
                systemClock,
                notifCollection,
                headsUpManager,
                headsUpViewBinder,
                visualInterruptionDecisionProvider,
                remoteInputManager,
                launchFullScreenIntentProvider,
                flags,
                statusBarNotificationChipsInteractor,
                headerController,
                executor
                executor,
            )
        coordinator.attach(notifPipeline)

@@ -351,7 +368,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        assertFalse(
            notifLifetimeExtender.maybeExtendLifetime(
                NotificationEntryBuilder().setPkg("test-package").build(),
                /* reason= */ 0
                /* reason= */ 0,
            )
        )
    }
@@ -441,6 +458,97 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        notifLifetimeExtender.cancelLifetimeExtension(entry)
    }

    @Test
    @EnableFlags(StatusBarNotifChips.FLAG_NAME)
    fun showPromotedNotification_hasNotifEntry_shownAsHUN() =
        testScope.runTest {
            whenever(notifCollection.getEntry(entry.key)).thenReturn(entry)

            statusBarNotificationChipsInteractor.onPromotedNotificationChipTapped(entry.key)
            executor.advanceClockToLast()
            executor.runAllReady()
            beforeFinalizeFilterListener.onBeforeFinalizeFilter(listOf(entry))

            finishBind(entry)
            verify(headsUpManager).showNotification(entry)
        }

    @Test
    @EnableFlags(StatusBarNotifChips.FLAG_NAME)
    fun showPromotedNotification_noNotifEntry_noHUN() =
        testScope.runTest {
            whenever(notifCollection.getEntry(entry.key)).thenReturn(null)

            statusBarNotificationChipsInteractor.onPromotedNotificationChipTapped(entry.key)
            executor.advanceClockToLast()
            executor.runAllReady()
            beforeFinalizeFilterListener.onBeforeFinalizeFilter(listOf(entry))

            verify(headsUpViewBinder, never()).bindHeadsUpView(eq(entry), any())
            verify(headsUpManager, never()).showNotification(entry)
        }

    @Test
    @EnableFlags(StatusBarNotifChips.FLAG_NAME)
    fun showPromotedNotification_shownAsHUNEvenIfEntryShouldNot() =
        testScope.runTest {
            whenever(notifCollection.getEntry(entry.key)).thenReturn(entry)

            // First, add the entry as shouldn't HUN
            setShouldHeadsUp(entry, false)
            collectionListener.onEntryAdded(entry)
            beforeTransformGroupsListener.onBeforeTransformGroups(listOf(entry))
            beforeFinalizeFilterListener.onBeforeFinalizeFilter(listOf(entry))

            // WHEN that entry becomes a promoted notification and is tapped
            statusBarNotificationChipsInteractor.onPromotedNotificationChipTapped(entry.key)
            executor.advanceClockToLast()
            executor.runAllReady()
            beforeFinalizeFilterListener.onBeforeFinalizeFilter(listOf(entry))

            // THEN it's still shown as heads up
            finishBind(entry)
            verify(headsUpManager).showNotification(entry)
        }

    @Test
    @EnableFlags(StatusBarNotifChips.FLAG_NAME)
    fun showPromotedNotification_atSameTimeAsOnAdded_promotedShownAsHUN() =
        testScope.runTest {
            // First, the promoted notification appears as not heads up
            val promotedEntry = NotificationEntryBuilder().setPkg("promotedPackage").build()
            whenever(notifCollection.getEntry(promotedEntry.key)).thenReturn(promotedEntry)
            setShouldHeadsUp(promotedEntry, false)

            collectionListener.onEntryAdded(promotedEntry)
            beforeTransformGroupsListener.onBeforeTransformGroups(listOf(promotedEntry))
            beforeFinalizeFilterListener.onBeforeFinalizeFilter(listOf(promotedEntry))

            verify(headsUpViewBinder, never()).bindHeadsUpView(eq(promotedEntry), any())
            verify(headsUpManager, never()).showNotification(promotedEntry)

            // Then a new notification comes in that should be heads up
            setShouldHeadsUp(entry, false)
            whenever(notifCollection.getEntry(entry.key)).thenReturn(entry)
            collectionListener.onEntryAdded(entry)

            // At the same time, the promoted notification chip is tapped
            statusBarNotificationChipsInteractor.onPromotedNotificationChipTapped(promotedEntry.key)
            executor.advanceClockToLast()
            executor.runAllReady()

            // WHEN we finalize the pipeline
            beforeTransformGroupsListener.onBeforeTransformGroups(listOf(promotedEntry, entry))
            beforeFinalizeFilterListener.onBeforeFinalizeFilter(listOf(promotedEntry, entry))

            // THEN the promoted entry is shown as a HUN, *not* the new entry
            finishBind(promotedEntry)
            verify(headsUpManager).showNotification(promotedEntry)

            verify(headsUpViewBinder, never()).bindHeadsUpView(eq(entry), any())
            verify(headsUpManager, never()).showNotification(entry)
        }

    @Test
    fun testTransferIsolatedChildAlert_withGroupAlertSummary() {
        setShouldHeadsUp(groupSummary)
@@ -862,7 +970,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        verify(launchFullScreenIntentProvider).launchFullScreenIntent(entry)
        verifyLoggedFullScreenIntentDecision(
            entry,
            FullScreenIntentDecision.FSI_DEVICE_NOT_INTERACTIVE
            FullScreenIntentDecision.FSI_DEVICE_NOT_INTERACTIVE,
        )
    }

@@ -885,7 +993,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        verify(launchFullScreenIntentProvider, never()).launchFullScreenIntent(any())
        verifyLoggedFullScreenIntentDecision(
            entry,
            FullScreenIntentDecision.NO_FSI_SUPPRESSED_ONLY_BY_DND
            FullScreenIntentDecision.NO_FSI_SUPPRESSED_ONLY_BY_DND,
        )
    }

@@ -899,7 +1007,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        verify(launchFullScreenIntentProvider, never()).launchFullScreenIntent(any())
        verifyLoggedFullScreenIntentDecision(
            entry,
            FullScreenIntentDecision.NO_FSI_SUPPRESSED_ONLY_BY_DND
            FullScreenIntentDecision.NO_FSI_SUPPRESSED_ONLY_BY_DND,
        )
        clearInterruptionProviderInvocations()

@@ -917,7 +1025,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        verify(headsUpManager, never()).showNotification(any())
        verifyLoggedFullScreenIntentDecision(
            entry,
            FullScreenIntentDecision.FSI_DEVICE_NOT_INTERACTIVE
            FullScreenIntentDecision.FSI_DEVICE_NOT_INTERACTIVE,
        )
        clearInterruptionProviderInvocations()

@@ -942,7 +1050,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        verify(launchFullScreenIntentProvider, never()).launchFullScreenIntent(any())
        verifyLoggedFullScreenIntentDecision(
            entry,
            FullScreenIntentDecision.NO_FSI_SUPPRESSED_ONLY_BY_DND
            FullScreenIntentDecision.NO_FSI_SUPPRESSED_ONLY_BY_DND,
        )
        clearInterruptionProviderInvocations()

@@ -975,7 +1083,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {
        verify(headsUpManager, never()).showNotification(any())
        verifyLoggedFullScreenIntentDecision(
            entry,
            FullScreenIntentDecision.FSI_DEVICE_NOT_INTERACTIVE
            FullScreenIntentDecision.FSI_DEVICE_NOT_INTERACTIVE,
        )
        clearInterruptionProviderInvocations()
    }
@@ -1070,7 +1178,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {

    private fun setShouldFullScreen(
        entry: NotificationEntry,
        originalDecision: FullScreenIntentDecision
        originalDecision: FullScreenIntentDecision,
    ) {
        whenever(visualInterruptionDecisionProvider.makeUnloggedFullScreenIntentDecision(entry))
            .thenAnswer { FullScreenIntentDecisionImpl(entry, originalDecision) }
@@ -1078,7 +1186,7 @@ class HeadsUpCoordinatorTest : SysuiTestCase() {

    private fun verifyLoggedFullScreenIntentDecision(
        entry: NotificationEntry,
        originalDecision: FullScreenIntentDecision
        originalDecision: FullScreenIntentDecision,
    ) {
        val decision = withArgCaptor {
            verify(visualInterruptionDecisionProvider).logFullScreenIntentDecision(capture())
+48 −0
Original line number Diff line number Diff line
/*
 * 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 android.annotation.SuppressLint
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow

/** An interactor for the notification chips shown in the status bar. */
@SysUISingleton
class StatusBarNotificationChipsInteractor @Inject constructor() {

    // 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
    // problems.
    @SuppressLint("SharedFlowCreation")
    private val _promotedNotificationChipTapEvent = MutableSharedFlow<String>()

    /**
     * SharedFlow that emits each time a promoted notification's status bar chip is tapped. The
     * emitted value is the promoted notification's key.
     */
    val promotedNotificationChipTapEvent: SharedFlow<String> =
        _promotedNotificationChipTapEvent.asSharedFlow()

    suspend fun onPromotedNotificationChipTapped(key: String) {
        StatusBarNotifChips.assertInNewMode()
        _promotedNotificationChipTapEvent.emit(key)
    }
}
+20 −3
Original line number Diff line number Diff line
@@ -16,20 +16,30 @@

package com.android.systemui.statusbar.chips.notification.ui.viewmodel

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.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
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

/** A view model for status bar chips for promoted ongoing notifications. */
@SysUISingleton
class NotifChipsViewModel
@Inject
constructor(activeNotificationsInteractor: ActiveNotificationsInteractor) {
constructor(
    @Application private val applicationScope: CoroutineScope,
    activeNotificationsInteractor: ActiveNotificationsInteractor,
    private val notifChipsInteractor: StatusBarNotificationChipsInteractor,
) {
    /**
     * A flow modeling the notification chips that should be shown. Emits an empty list if there are
     * no notifications that should show a status bar chip.
@@ -44,13 +54,20 @@ constructor(activeNotificationsInteractor: ActiveNotificationsInteractor) {
     * notification has invalid data such that it can't be displayed as a chip.
     */
    private fun ActiveNotificationModel.toChipModel(): 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)
        // TODO(b/364653005): Use the notification color if applicable.
        val colors = ColorsModel.Themed
        // TODO(b/364653005): When the chip is clicked, show the HUN.
        val onClickListener = null
        val onClickListener =
            View.OnClickListener {
                // 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)
                }
            }
        return OngoingActivityChipModel.Shown.ShortTimeDelta(
            icon,
            colors,
Loading