diff options
9 files changed, 403 insertions, 34 deletions
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 new file mode 100644 index 000000000000..19ed6a57d2f0 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorTest.kt @@ -0,0 +1,70 @@ +/* + * 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") + } +} 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 eb5d9318c88f..6e190965d08b 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 @@ -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() 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 76bb8de71bdd..9613f76c2b48 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 @@ -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, ) ) } @@ -442,6 +459,97 @@ class HeadsUpCoordinatorTest : SysuiTestCase() { } @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) whenever(notifPipeline.allNotifs).thenReturn(listOf(groupSummary, groupSibling1)) @@ -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()) 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 new file mode 100644 index 000000000000..9e09671bc7bf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractor.kt @@ -0,0 +1,48 @@ +/* + * 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) + } +} 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 6ae92637bde7..c8d3f339b3e9 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 @@ -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, diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinator.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinator.kt index ec8566b82aea..c7b47eeec218 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinator.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinator.kt @@ -20,11 +20,15 @@ import android.app.Notification.GROUP_ALERT_SUMMARY import android.util.ArrayMap import android.util.ArraySet import com.android.internal.annotations.VisibleForTesting +import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main 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.NotifPipelineFlags import com.android.systemui.statusbar.notification.collection.GroupEntry import com.android.systemui.statusbar.notification.collection.ListEntry +import com.android.systemui.statusbar.notification.collection.NotifCollection import com.android.systemui.statusbar.notification.collection.NotifPipeline import com.android.systemui.statusbar.notification.collection.NotificationEntry import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope @@ -48,6 +52,8 @@ import com.android.systemui.util.concurrency.DelayableExecutor import com.android.systemui.util.time.SystemClock import java.util.function.Consumer import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Coordinates heads up notification (HUN) interactions with the notification pipeline based on the @@ -67,16 +73,19 @@ import javax.inject.Inject class HeadsUpCoordinator @Inject constructor( + @Application private val applicationScope: CoroutineScope, private val mLogger: HeadsUpCoordinatorLogger, private val mSystemClock: SystemClock, + private val notifCollection: NotifCollection, private val mHeadsUpManager: HeadsUpManager, private val mHeadsUpViewBinder: HeadsUpViewBinder, private val mVisualInterruptionDecisionProvider: VisualInterruptionDecisionProvider, private val mRemoteInputManager: NotificationRemoteInputManager, private val mLaunchFullScreenIntentProvider: LaunchFullScreenIntentProvider, private val mFlags: NotifPipelineFlags, + private val statusBarNotificationChipsInteractor: StatusBarNotificationChipsInteractor, @IncomingHeader private val mIncomingHeaderController: NodeController, - @Main private val mExecutor: DelayableExecutor + @Main private val mExecutor: DelayableExecutor, ) : Coordinator { private val mEntriesBindingUntil = ArrayMap<String, Long>() private val mEntriesUpdateTimes = ArrayMap<String, Long>() @@ -98,6 +107,52 @@ constructor( pipeline.addPromoter(mNotifPromoter) pipeline.addNotificationLifetimeExtender(mLifetimeExtender) mRemoteInputManager.addActionPressListener(mActionPressListener) + + if (StatusBarNotifChips.isEnabled) { + applicationScope.launch { + statusBarNotificationChipsInteractor.promotedNotificationChipTapEvent.collect { + showPromotedNotificationHeadsUp(it) + } + } + } + } + + /** + * Shows the promoted notification with the given [key] as heads-up. + * + * Must be run on the main thread. + */ + private fun showPromotedNotificationHeadsUp(key: String) { + StatusBarNotifChips.assertInNewMode() + mLogger.logShowPromotedNotificationHeadsUp(key) + + val entry = notifCollection.getEntry(key) + if (entry == null) { + mLogger.logPromotedNotificationForHeadsUpNotFound(key) + return + } + // TODO(b/364653005): Validate that the given key indeed matches a promoted notification, + // not just any notification. + + val posted = + PostedEntry( + entry, + wasAdded = false, + wasUpdated = false, + // Force-set this notification to show heads-up. + // TODO(b/364653005): This means that if you tap on the second notification chip, + // then it moves to become the first chip because whatever notification is showing + // heads-up is considered to be the top notification. + shouldHeadsUpEver = true, + shouldHeadsUpAgain = true, + isHeadsUpEntry = mHeadsUpManager.isHeadsUpEntry(entry.key), + isBinding = isEntryBinding(entry), + ) + + mExecutor.execute { + mPostedEntries[entry.key] = posted + mNotifPromoter.invalidateList("showPromotedNotificationHeadsUp: ${entry.logKey}") + } } private fun onHeadsUpViewBound(entry: NotificationEntry) { @@ -222,7 +277,7 @@ constructor( logicalSummary.setInterruption() mLogger.logSummaryMarkedInterrupted( logicalSummary.key, - childToReceiveParentHeadsUp.key + childToReceiveParentHeadsUp.key, ) // If the summary was not attached, then remove the heads up from the detached @@ -246,12 +301,12 @@ constructor( handlePostedEntry( summaryUpdateForRemoval, hunMutator, - scenario = "detached-summary-remove-heads-up" + scenario = "detached-summary-remove-heads-up", ) } else if (summaryUpdate != null) { mLogger.logPostedEntryWillNotEvaluate( summaryUpdate, - reason = "attached-summary-transferred" + reason = "attached-summary-transferred", ) } @@ -270,14 +325,14 @@ constructor( handlePostedEntry( postedEntry, hunMutator, - scenario = "child-heads-up-transfer-target-$targetType" + scenario = "child-heads-up-transfer-target-$targetType", ) didHeadsUpChildToReceiveParentHeadsUp = true } else { handlePostedEntry( postedEntry, hunMutator, - scenario = "child-heads-up-non-target" + scenario = "child-heads-up-non-target", ) } } @@ -301,7 +356,7 @@ constructor( handlePostedEntry( posted, hunMutator, - scenario = "non-posted-child-heads-up-transfer-target-$targetType" + scenario = "non-posted-child-heads-up-transfer-target-$targetType", ) } } @@ -345,10 +400,7 @@ constructor( .filter { !it.sbn.notification.isGroupSummary } .filter { locationLookupByKey(it.key) != GroupLocation.Detached } .sortedWith( - compareBy( - { !mPostedEntries.contains(it.key) }, - { -it.sbn.notification.getWhen() }, - ) + compareBy({ !mPostedEntries.contains(it.key) }, { -it.sbn.notification.getWhen() }) ) .firstOrNull() @@ -499,7 +551,7 @@ constructor( mHeadsUpManager.removeNotification( posted.key, /* removeImmediately= */ false, - "onEntryUpdated" + "onEntryUpdated", ) } else if (posted.isBinding) { // Don't let the bind finish @@ -527,7 +579,7 @@ constructor( mHeadsUpManager.removeNotification( entry.key, removeImmediatelyForRemoteInput, - "onEntryRemoved, reason: $reason" + "onEntryRemoved, reason: $reason", ) } } @@ -593,7 +645,7 @@ constructor( // for FSI reconsideration mLogger.logEntryDisqualifiedFromFullScreen( entry.key, - decision.logReason + decision.logReason, ) mVisualInterruptionDecisionProvider.logFullScreenIntentDecision( decision @@ -619,7 +671,7 @@ constructor( mLogger.logEntryUpdatedByRanking( entry.key, shouldHeadsUpEver, - decision.logReason + decision.logReason, ) onEntryUpdated(entry) } @@ -731,10 +783,10 @@ constructor( entry.key, /* releaseImmediately */ true, "cancel lifetime extension - extended for reason: " + - "$reason, isSticky: true" + "$reason, isSticky: true", ) }, - removeAfterMillis + removeAfterMillis, ) } else { mExecutor.execute { @@ -742,7 +794,7 @@ constructor( entry.key, /* releaseImmediately */ false, "lifetime extension - extended for reason: $reason" + - ", isSticky: false" + ", isSticky: false", ) } mNotifsExtendingLifetime[entry] = null @@ -873,7 +925,7 @@ private enum class GroupLocation { Detached, Isolated, Summary, - Child + Child, } private fun Map<String, GroupLocation>.getLocation(key: String): GroupLocation = diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorLogger.kt index 1a521d767438..e443a0418ffd 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/HeadsUpCoordinatorLogger.kt @@ -138,4 +138,22 @@ class HeadsUpCoordinatorLogger(private val buffer: LogBuffer, private val verbos { "marked group summary as interrupted: $str1 for alert transfer to child: $str2" }, ) } + + fun logShowPromotedNotificationHeadsUp(key: String) { + buffer.log( + TAG, + LogLevel.DEBUG, + { str1 = key }, + { "requesting promoted entry to show heads up: $str1" }, + ) + } + + fun logPromotedNotificationForHeadsUpNotFound(key: String) { + buffer.log( + TAG, + LogLevel.DEBUG, + { str1 = key }, + { "could not find promoted entry, so not showing heads up: $str1" }, + ) + } } 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 new file mode 100644 index 000000000000..74c7611a6392 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/StatusBarNotificationChipsInteractorKosmos.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.interactor + +import com.android.systemui.kosmos.Kosmos + +val Kosmos.statusBarNotificationChipsInteractor: StatusBarNotificationChipsInteractor by + Kosmos.Fixture { StatusBarNotificationChipsInteractor() } 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 af24c371d62b..68b28adb4b3a 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 @@ -17,7 +17,15 @@ 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(activeNotificationsInteractor) } + Kosmos.Fixture { + NotifChipsViewModel( + applicationCoroutineScope, + activeNotificationsInteractor, + statusBarNotificationChipsInteractor, + ) + } |