diff options
| author | 2024-11-23 02:11:58 +0000 | |
|---|---|---|
| committer | 2024-11-23 02:11:58 +0000 | |
| commit | 64c4818c47b1de5b43b84b1734248200af4b83d9 (patch) | |
| tree | e5cca7e64b8020a8b18c5404c1697cd8cea141c6 | |
| parent | d9192ee983f4b52968befcf29cf6d0e6a6b044e3 (diff) | |
| parent | acc477c4eff08896d58ca9aaa3802e19a31bb094 (diff) | |
Merge changes I3012b6a9,I3f47da63 into main
* changes:
[SB][Notifs] Hide status bar notif chip if the app is open.
[SB][Notifs] Create individual interactors for each status bar chip.
19 files changed, 1157 insertions, 50 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/activity/data/repository/ActivityManagerRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/activity/data/repository/ActivityManagerRepositoryTest.kt new file mode 100644 index 000000000000..d6ba98d65d15 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/activity/data/repository/ActivityManagerRepositoryTest.kt @@ -0,0 +1,143 @@ +/* + * 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.activity.data.repository + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING +import android.app.activityManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.collectLastValue +import com.android.systemui.kosmos.runTest +import com.android.systemui.kosmos.useUnconfinedTestDispatcher +import com.android.systemui.log.core.Logger +import com.android.systemui.log.logcatLogBuffer +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.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SmallTest +@RunWith(AndroidJUnit4::class) +class ActivityManagerRepositoryTest : SysuiTestCase() { + private val kosmos = testKosmos().useUnconfinedTestDispatcher() + private val logger = Logger(logcatLogBuffer("ActivityManagerRepositoryTest"), "tag") + + private val Kosmos.underTest by Kosmos.Fixture { realActivityManagerRepository } + + @Test + fun createIsAppVisibleFlow_fetchesInitialValue_true() = + kosmos.runTest { + whenever(activityManager.getUidImportance(THIS_UID)).thenReturn(IMPORTANCE_FOREGROUND) + + val latest by + collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG)) + + assertThat(latest).isTrue() + } + + @Test + fun createIsAppVisibleFlow_fetchesInitialValue_false() = + kosmos.runTest { + whenever(activityManager.getUidImportance(THIS_UID)).thenReturn(IMPORTANCE_GONE) + + val latest by + collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG)) + + assertThat(latest).isFalse() + } + + @Test + fun createIsAppVisibleFlow_getsImportanceUpdates() = + kosmos.runTest { + val latest by + collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG)) + + val listenerCaptor = argumentCaptor<ActivityManager.OnUidImportanceListener>() + verify(activityManager).addOnUidImportanceListener(listenerCaptor.capture(), any()) + val listener = listenerCaptor.firstValue + + listener.onUidImportance(THIS_UID, IMPORTANCE_GONE) + assertThat(latest).isFalse() + + listener.onUidImportance(THIS_UID, IMPORTANCE_FOREGROUND) + assertThat(latest).isTrue() + + listener.onUidImportance(THIS_UID, IMPORTANCE_TOP_SLEEPING) + assertThat(latest).isFalse() + } + + @Test + fun createIsAppVisibleFlow_ignoresUpdatesForOtherUids() = + kosmos.runTest { + val latest by + collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG)) + + val listenerCaptor = argumentCaptor<ActivityManager.OnUidImportanceListener>() + verify(activityManager).addOnUidImportanceListener(listenerCaptor.capture(), any()) + val listener = listenerCaptor.firstValue + + listener.onUidImportance(THIS_UID, IMPORTANCE_GONE) + assertThat(latest).isFalse() + + // WHEN another UID becomes foreground + listener.onUidImportance(THIS_UID + 2, IMPORTANCE_FOREGROUND) + + // THEN this UID still stays not visible + assertThat(latest).isFalse() + } + + @Test + fun createIsAppVisibleFlow_securityExceptionOnUidRegistration_ok() = + kosmos.runTest { + whenever(activityManager.getUidImportance(THIS_UID)).thenReturn(IMPORTANCE_GONE) + whenever(activityManager.addOnUidImportanceListener(any(), any())) + .thenThrow(SecurityException()) + + val latest by + collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG)) + + // Verify no crash, and we get a value emitted + assertThat(latest).isFalse() + } + + /** Regression test for b/216248574. */ + @Test + fun createIsAppVisibleFlow_getUidImportanceThrowsException_ok() = + kosmos.runTest { + whenever(activityManager.getUidImportance(any())).thenThrow(SecurityException()) + + val latest by + collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG)) + + // Verify no crash, and we get a value emitted + assertThat(latest).isFalse() + } + + companion object { + private const val THIS_UID = 558 + private const val LOG_TAG = "LogTag" + } +} 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..7fed47a4653e --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorTest.kt @@ -0,0 +1,207 @@ +/* + * 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.activity.data.repository.activityManagerRepository +import com.android.systemui.activity.data.repository.fake +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.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 factory = kosmos.singleNotificationChipInteractorFactory + + @Test + fun notificationChip_startsWithStartingModel() = + kosmos.runTest { + val icon = mock<StatusBarIconView>() + val startingNotif = activeNotificationModel(key = "notif1", statusBarChipIcon = icon) + + val underTest = factory.create(startingNotif) + + 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 = + factory.create( + activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView) + ) + + 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 = + factory.create( + activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView) + ) + + 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 = + factory.create(activeNotificationModel(key = "notif1", statusBarChipIcon = null)) + + 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 = factory.create(startingNotif) + val latest by collectLastValue(underTest.notificationChip) + assertThat(latest).isNotNull() + + underTest.setNotification( + activeNotificationModel(key = "notif1", statusBarChipIcon = null) + ) + + assertThat(latest).isNull() + } + + @Test + fun notificationChip_appIsVisibleOnCreation_emitsNull() = + kosmos.runTest { + activityManagerRepository.fake.startingIsAppVisibleValue = true + + val underTest = + factory.create( + activeNotificationModel(key = "notif", uid = UID, statusBarChipIcon = mock()) + ) + + val latest by collectLastValue(underTest.notificationChip) + + assertThat(latest).isNull() + } + + @Test + fun notificationChip_appNotVisibleOnCreation_emitsValue() = + kosmos.runTest { + activityManagerRepository.fake.startingIsAppVisibleValue = false + + val underTest = + factory.create( + activeNotificationModel(key = "notif", uid = UID, statusBarChipIcon = mock()) + ) + + val latest by collectLastValue(underTest.notificationChip) + + assertThat(latest).isNotNull() + } + + @Test + fun notificationChip_hidesWhenAppIsVisible() = + kosmos.runTest { + val underTest = + factory.create( + activeNotificationModel(key = "notif", uid = UID, statusBarChipIcon = mock()) + ) + + val latest by collectLastValue(underTest.notificationChip) + + activityManagerRepository.fake.setIsAppVisible(UID, false) + assertThat(latest).isNotNull() + + activityManagerRepository.fake.setIsAppVisible(UID, true) + assertThat(latest).isNull() + + activityManagerRepository.fake.setIsAppVisible(UID, false) + assertThat(latest).isNotNull() + } + + // Note: This test is theoretically impossible because the notification key should contain the + // UID, so if the UID changes then the key would also change and a new interactor would be + // created. But, test it just in case. + @Test + fun notificationChip_updatedUid_rechecksAppVisibility_oldObserverUnregistered() = + kosmos.runTest { + activityManagerRepository.fake.startingIsAppVisibleValue = false + + val hiddenUid = 100 + val shownUid = 101 + + val underTest = + factory.create( + activeNotificationModel( + key = "notif", + uid = hiddenUid, + statusBarChipIcon = mock(), + ) + ) + val latest by collectLastValue(underTest.notificationChip) + assertThat(latest).isNotNull() + + // WHEN the notif gets a new UID that starts as visible + activityManagerRepository.fake.startingIsAppVisibleValue = true + underTest.setNotification( + activeNotificationModel(key = "notif", uid = shownUid, statusBarChipIcon = mock()) + ) + + // THEN we re-fetch the app visibility state with the new UID, and since that UID is + // visible, we hide the chip + assertThat(latest).isNull() + } + + companion object { + private const val UID = 885 + } +} 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 e5d2cf65a389..1d7f25784327 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 @@ -83,7 +83,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/activity/ActivityManagerModule.kt b/packages/SystemUI/src/com/android/systemui/activity/ActivityManagerModule.kt new file mode 100644 index 000000000000..db315e4e0bf7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/activity/ActivityManagerModule.kt @@ -0,0 +1,30 @@ +/* + * 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.activity + +import com.android.systemui.activity.data.repository.ActivityManagerRepository +import com.android.systemui.activity.data.repository.ActivityManagerRepositoryImpl +import com.android.systemui.dagger.SysUISingleton +import dagger.Binds +import dagger.Module + +@Module +interface ActivityManagerModule { + @Binds + @SysUISingleton + fun activityManagerRepository(impl: ActivityManagerRepositoryImpl): ActivityManagerRepository +} diff --git a/packages/SystemUI/src/com/android/systemui/activity/data/repository/ActivityManagerRepository.kt b/packages/SystemUI/src/com/android/systemui/activity/data/repository/ActivityManagerRepository.kt new file mode 100644 index 000000000000..94614b70beda --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/activity/data/repository/ActivityManagerRepository.kt @@ -0,0 +1,118 @@ +/* + * 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.activity.data.repository + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.log.core.Logger +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart + +/** Repository for interfacing with [ActivityManager]. */ +interface ActivityManagerRepository { + /** + * Given a UID, creates a flow that emits true when the process with the given UID is visible to + * the user and false otherwise. + * + * @param identifyingLogTag a tag identifying who created this flow, used for logging. + */ + fun createIsAppVisibleFlow( + creationUid: Int, + logger: Logger, + identifyingLogTag: String, + ): Flow<Boolean> +} + +@SysUISingleton +class ActivityManagerRepositoryImpl +@Inject +constructor( + @Background private val backgroundContext: CoroutineContext, + private val activityManager: ActivityManager, +) : ActivityManagerRepository { + override fun createIsAppVisibleFlow( + creationUid: Int, + logger: Logger, + identifyingLogTag: String, + ): Flow<Boolean> { + return conflatedCallbackFlow { + val listener = + object : ActivityManager.OnUidImportanceListener { + override fun onUidImportance(uid: Int, importance: Int) { + if (uid != creationUid) { + return + } + val isAppVisible = isAppVisibleToUser(importance) + logger.d({ + "$str1: #onUidImportance. importance=$int1, isAppVisible=$bool1" + }) { + str1 = identifyingLogTag + int1 = importance + bool1 = isAppVisible + } + trySend(isAppVisible) + } + } + try { + // TODO(b/286258140): Replace this with the #addOnUidImportanceListener + // overload that filters to certain UIDs. + activityManager.addOnUidImportanceListener(listener, IMPORTANCE_CUTPOINT) + } catch (e: SecurityException) { + logger.e({ "$str1: Security exception on #addOnUidImportanceListener" }, e) { + str1 = identifyingLogTag + } + } + + awaitClose { activityManager.removeOnUidImportanceListener(listener) } + } + .distinctUntilChanged() + .onStart { + try { + val isVisibleOnStart = + isAppVisibleToUser(activityManager.getUidImportance(creationUid)) + logger.d({ "$str1: Starting UID observation. isAppVisible=$bool1" }) { + str1 = identifyingLogTag + bool1 = isVisibleOnStart + } + emit(isVisibleOnStart) + } catch (e: SecurityException) { + logger.e({ "$str1: Security exception on #getUidImportance" }, e) { + str1 = identifyingLogTag + } + emit(false) + } + } + .flowOn(backgroundContext) + } + + /** Returns true if the given [importance] represents an app that's visible to the user. */ + private fun isAppVisibleToUser(importance: Int): Boolean { + return importance <= IMPORTANCE_CUTPOINT + } + + companion object { + private const val IMPORTANCE_CUTPOINT = IMPORTANCE_FOREGROUND + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 6a777ee7417b..d6f8957ace33 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -32,6 +32,7 @@ import com.android.systemui.BootCompleteCacheImpl; import com.android.systemui.CameraProtectionModule; import com.android.systemui.CoreStartable; import com.android.systemui.SystemUISecondaryUserService; +import com.android.systemui.activity.ActivityManagerModule; import com.android.systemui.ambient.dagger.AmbientModule; import com.android.systemui.appops.dagger.AppOpsModule; import com.android.systemui.assist.AssistModule; @@ -198,6 +199,7 @@ import javax.inject.Named; * may not appreciate that. */ @Module(includes = { + ActivityManagerModule.class, AmbientModule.class, AppOpsModule.class, AssistModule.class, 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..087b51032fcf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractor.kt @@ -0,0 +1,115 @@ +/* + * 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.activity.data.repository.ActivityManagerRepository +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +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. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SingleNotificationChipInteractor +@AssistedInject +constructor( + @Assisted startingModel: ActiveNotificationModel, + private val activityManagerRepository: ActivityManagerRepository, + @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 + } + + private val uid: Flow<Int> = _notificationModel.map { it.uid } + + /** True if the application managing the notification is visible to the user. */ + private val isAppVisible: Flow<Boolean> = + uid.flatMapLatest { currentUid -> + activityManagerRepository.createIsAppVisibleFlow(currentUid, logger, extraLogTag) + } + + /** + * Emits this notification's status bar chip, or null if this notification shouldn't show a + * status bar chip. + */ + val notificationChip: Flow<NotificationChipModel?> = + combine(_notificationModel, isAppVisible) { notif, isAppVisible -> + if (isAppVisible) { + // If the app that posted this notification is visible, we want to hide the chip + // because information between the status bar chip and the app itself could be + // out-of-sync (like a timer that's slightly off) + null + } else { + 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/src/com/android/systemui/statusbar/phone/ongoingcall/OngoingCallController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/phone/ongoingcall/OngoingCallController.kt index aac2cd1755d0..78926c78a368 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/ongoingcall/OngoingCallController.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/ongoingcall/OngoingCallController.kt @@ -25,6 +25,7 @@ import android.app.UidObserver import android.content.Context import android.view.View import androidx.annotation.VisibleForTesting +import com.android.app.tracing.coroutines.launchTraced as launch import com.android.internal.jank.InteractionJankMonitor import com.android.systemui.CoreStartable import com.android.systemui.Dumpable @@ -58,7 +59,6 @@ import java.io.PrintWriter import java.util.concurrent.Executor import javax.inject.Inject import kotlinx.coroutines.CoroutineScope -import com.android.app.tracing.coroutines.launchTraced as launch /** A controller to handle the ongoing call chip in the collapsed status bar. */ @SysUISingleton @@ -122,9 +122,9 @@ constructor( entry.sbn.uid, entry.sbn.notification.extras.getInt( Notification.EXTRA_CALL_TYPE, - -1 + -1, ) == CALL_TYPE_ONGOING, - statusBarSwipedAway = callNotificationInfo?.statusBarSwipedAway ?: false + statusBarSwipedAway = callNotificationInfo?.statusBarSwipedAway ?: false, ) if (newOngoingCallInfo == callNotificationInfo) { return @@ -236,7 +236,7 @@ constructor( bool1 = Flags.statusBarCallChipNotificationIcon() bool2 = currentInfo.notificationIconView != null }, - { "Creating OngoingCallModel.InCall. notifIconFlag=$bool1 hasIcon=$bool2" } + { "Creating OngoingCallModel.InCall. notifIconFlag=$bool1 hasIcon=$bool2" }, ) val icon = if (Flags.statusBarCallChipNotificationIcon()) { @@ -288,7 +288,7 @@ constructor( str1 = notifModel.callType.name bool1 = notifModel.statusBarChipIconView != null }, - { "NotifInteractorCallModel: key=$str1 when=$long1 callType=$str2 hasIcon=$bool1" } + { "NotifInteractorCallModel: key=$str1 when=$long1 callType=$str2 hasIcon=$bool1" }, ) val newOngoingCallInfo = @@ -299,7 +299,7 @@ constructor( notifModel.contentIntent, notifModel.uid, isOngoing = true, - statusBarSwipedAway = callNotificationInfo?.statusBarSwipedAway ?: false + statusBarSwipedAway = callNotificationInfo?.statusBarSwipedAway ?: false, ) if (newOngoingCallInfo == callNotificationInfo) { return @@ -378,7 +378,7 @@ constructor( ActivityTransitionAnimator.Controller.fromView( backgroundView, InteractionJankMonitor.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP, - ) + ), ) } } @@ -455,7 +455,7 @@ constructor( /** True if the call is currently ongoing (as opposed to incoming, screening, etc.). */ val isOngoing: Boolean, /** True if the user has swiped away the status bar while in this phone call. */ - val statusBarSwipedAway: Boolean + val statusBarSwipedAway: Boolean, ) { /** * Returns true if the notification information has a valid call start time. See @@ -472,6 +472,9 @@ constructor( /** * Observer to tell us when the app that posted the ongoing call notification is visible so that * we don't show the call chip at the same time (since the timers could be out-of-sync). + * + * For a more recommended architecture implementation, see + * [com.android.systemui.activity.data.repository.ActivityManagerRepository]. */ inner class CallAppUidObserver : UidObserver() { /** True if the application managing the call is visible to the user. */ @@ -512,7 +515,7 @@ constructor( uidObserver, ActivityManager.UID_OBSERVER_PROCSTATE, ActivityManager.PROCESS_STATE_UNKNOWN, - context.opPackageName + context.opPackageName, ) isRegistered = true } catch (se: SecurityException) { @@ -537,7 +540,7 @@ constructor( uid: Int, procState: Int, procStateSeq: Long, - capability: Int + capability: Int, ) { val currentCallAppUid = callAppUid ?: return if (uid != currentCallAppUid) { diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/activity/data/repository/ActivityManagerRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/activity/data/repository/ActivityManagerRepositoryKosmos.kt new file mode 100644 index 000000000000..a6e71333c816 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/activity/data/repository/ActivityManagerRepositoryKosmos.kt @@ -0,0 +1,51 @@ +/* + * 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.activity.data.repository + +import android.app.activityManager +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.log.core.Logger +import kotlinx.coroutines.flow.MutableStateFlow + +val Kosmos.activityManagerRepository by Kosmos.Fixture { FakeActivityManagerRepository() } + +val Kosmos.realActivityManagerRepository by + Kosmos.Fixture { ActivityManagerRepositoryImpl(testDispatcher, activityManager) } + +class FakeActivityManagerRepository : ActivityManagerRepository { + private val uidFlows = mutableMapOf<Int, MutableList<MutableStateFlow<Boolean>>>() + + var startingIsAppVisibleValue = false + + override fun createIsAppVisibleFlow( + creationUid: Int, + logger: Logger, + identifyingLogTag: String, + ): MutableStateFlow<Boolean> { + val newFlow = MutableStateFlow(startingIsAppVisibleValue) + uidFlows.computeIfAbsent(creationUid) { mutableListOf() }.add(newFlow) + return newFlow + } + + fun setIsAppVisible(uid: Int, isAppVisible: Boolean) { + uidFlows[uid]?.forEach { stateFlow -> stateFlow.value = isAppVisible } + } +} + +val ActivityManagerRepository.fake + get() = this as FakeActivityManagerRepository diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorFactoryKosmos.kt new file mode 100644 index 000000000000..1c095e11dffa --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/chips/notification/domain/interactor/SingleNotificationChipInteractorFactoryKosmos.kt @@ -0,0 +1,33 @@ +/* + * 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.activity.data.repository.activityManagerRepository +import com.android.systemui.activity.data.repository.fake +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.statusbar.chips.statusBarChipsLogger + +val Kosmos.singleNotificationChipInteractorFactory: SingleNotificationChipInteractor.Factory by + Kosmos.Fixture { + SingleNotificationChipInteractor.Factory { startingModel -> + SingleNotificationChipInteractor( + startingModel, + activityManagerRepository.fake, + logBuffer = statusBarChipsLogger, + ) + } + } 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..03e9f3d52ca3 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,16 @@ 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, + ) + } 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) } |