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

Commit 231d7dfd authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Notifs] Initial scaffolding for notification status bar chips.

This CL:
1) Adds `ActiveNotificationsInteractor.promotedOngoingNotifications`
   flow that will emit all the active promoted notifs. (Right now, it
   just emits every notification, so every notification will have a chip.)
2) Creates `NotifChipsViewModel`, which turns the notification objects
   into status bar chip model objects.
3) Adds a new `OngoingActivityChipModel.Shown.ShortTimeDelta` model,
   which supports showing short times like "15 min" or "1 hr".
4) Connects `NotifChipsViewModel` to `OngoingActivityChipsViewModel` to
   show the notif chips if we don't have any screen recording or call
   chips.

Bug: 364653005
Flag: com.android.systemui.status_bar_ron_chips
Test: With flag enabled, verify the top two notifications turn into
status bar chips
Test: With flag disabled, verify existing screen share and call chips
still work
Test: atest NotifChipsViewModelTest OngoingActivityChipsViewModelTest

Change-Id: I4b2dceb7d61f1c525b4c3ff150fbfcb1935f408b
parent fca8ee70
Loading
Loading
Loading
Loading
+122 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

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

import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.Flags.FLAG_STATUS_BAR_RON_CHIPS
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.ron.ui.viewmodel.notifChipsViewModel
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.mockito.kotlin.mock

@SmallTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
@EnableFlags(FLAG_STATUS_BAR_RON_CHIPS)
class NotifChipsViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val activeNotificationListRepository = kosmos.activeNotificationListRepository

    private val underTest = kosmos.notifChipsViewModel

    @Test
    fun chips_noNotifs_empty() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            setNotifs(emptyList())

            assertThat(latest).isEmpty()
        }

    @Test
    fun chips_notifMissingStatusBarChipIconView_empty() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            setNotifs(listOf(activeNotificationModel(key = "notif", statusBarChipIcon = null)))

            assertThat(latest).isEmpty()
        }

    @Test
    fun chips_oneNotif_statusBarIconViewMatches() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            val icon = mock<StatusBarIconView>()
            setNotifs(listOf(activeNotificationModel(key = "notif", statusBarChipIcon = icon)))

            assertThat(latest).hasSize(1)
            val chip = latest!![0]
            assertThat(chip).isInstanceOf(OngoingActivityChipModel.Shown.ShortTimeDelta::class.java)
            assertThat(chip.icon).isEqualTo(OngoingActivityChipModel.ChipIcon.StatusBarView(icon))
        }

    @Test
    fun chips_twoNotifs_twoChips() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            val firstIcon = mock<StatusBarIconView>()
            val secondIcon = mock<StatusBarIconView>()
            setNotifs(
                listOf(
                    activeNotificationModel(key = "notif1", statusBarChipIcon = firstIcon),
                    activeNotificationModel(key = "notif2", statusBarChipIcon = secondIcon),
                )
            )

            assertThat(latest).hasSize(2)
            assertIsNotifChip(latest!![0], firstIcon)
            assertIsNotifChip(latest!![1], secondIcon)
        }

    private fun setNotifs(notifs: List<ActiveNotificationModel>) {
        activeNotificationListRepository.activeNotifications.value =
            ActiveNotificationsStore.Builder()
                .apply { notifs.forEach { addIndividualNotif(it) } }
                .build()
        testScope.runCurrent()
    }

    companion object {
        fun assertIsNotifChip(latest: OngoingActivityChipModel?, expectedIcon: StatusBarIconView) {
            assertThat(latest)
                .isInstanceOf(OngoingActivityChipModel.Shown.ShortTimeDelta::class.java)
            assertThat((latest as OngoingActivityChipModel.Shown).icon)
                .isEqualTo(OngoingActivityChipModel.ChipIcon.StatusBarView(expectedIcon))
        }
    }
}
+107 −1
Original line number Diff line number Diff line
@@ -34,8 +34,10 @@ import com.android.systemui.mediaprojection.taskswitcher.FakeActivityTaskManager
import com.android.systemui.res.R
import com.android.systemui.screenrecord.data.model.ScreenRecordModel
import com.android.systemui.screenrecord.data.repository.screenRecordRepository
import com.android.systemui.statusbar.StatusBarIconView
import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractorTest.Companion.NORMAL_PACKAGE
import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractorTest.Companion.setUpPackageManagerForMediaProjection
import com.android.systemui.statusbar.chips.notification.ui.viewmodel.NotifChipsViewModelTest.Companion.assertIsNotifChip
import com.android.systemui.statusbar.chips.ron.demo.ui.viewmodel.DemoRonChipViewModelTest.Companion.addDemoRonChip
import com.android.systemui.statusbar.chips.ron.demo.ui.viewmodel.demoRonChipViewModel
import com.android.systemui.statusbar.chips.ui.model.MultipleOngoingActivityChipsModel
@@ -46,6 +48,10 @@ import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipsVie
import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipsViewModelTest.Companion.assertIsShareToAppChip
import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipsViewModelTest.Companion.getStopActionFromDialog
import com.android.systemui.statusbar.commandline.commandRegistry
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.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.ongoingcall.data.repository.ongoingCallRepository
import com.android.systemui.statusbar.phone.ongoingcall.shared.model.OngoingCallModel
@@ -83,6 +89,7 @@ class OngoingActivityChipsWithRonsViewModelTest : SysuiTestCase() {
    private val screenRecordState = kosmos.screenRecordRepository.screenRecordState
    private val mediaProjectionState = kosmos.fakeMediaProjectionRepository.mediaProjectionState
    private val callRepo = kosmos.ongoingCallRepository
    private val activeNotificationListRepository = kosmos.activeNotificationListRepository

    private val pw = PrintWriter(StringWriter())

@@ -107,7 +114,7 @@ class OngoingActivityChipsWithRonsViewModelTest : SysuiTestCase() {
        val icon =
            BitmapDrawable(
                context.resources,
                Bitmap.createBitmap(/* width= */ 100, /* height= */ 100, Bitmap.Config.ARGB_8888)
                Bitmap.createBitmap(/* width= */ 100, /* height= */ 100, Bitmap.Config.ARGB_8888),
            )
        whenever(kosmos.packageManager.getApplicationIcon(any<String>())).thenReturn(icon)
    }
@@ -287,6 +294,97 @@ class OngoingActivityChipsWithRonsViewModelTest : SysuiTestCase() {
            assertThat(latest!!.secondary).isInstanceOf(OngoingActivityChipModel.Hidden::class.java)
        }

    @Test
    fun chips_singleNotifChip_primaryIsNotifSecondaryIsHidden() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            val icon = mock<StatusBarIconView>()
            setNotifs(listOf(activeNotificationModel(key = "notif", statusBarChipIcon = icon)))

            assertIsNotifChip(latest!!.primary, icon)
            assertThat(latest!!.secondary).isInstanceOf(OngoingActivityChipModel.Hidden::class.java)
        }

    @Test
    fun chips_twoNotifChips_primaryAndSecondaryAreNotifsInOrder() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            val firstIcon = mock<StatusBarIconView>()
            val secondIcon = mock<StatusBarIconView>()
            setNotifs(
                listOf(
                    activeNotificationModel(key = "firstNotif", statusBarChipIcon = firstIcon),
                    activeNotificationModel(key = "secondNotif", statusBarChipIcon = secondIcon),
                )
            )

            assertIsNotifChip(latest!!.primary, firstIcon)
            assertIsNotifChip(latest!!.secondary, secondIcon)
        }

    @Test
    fun chips_threeNotifChips_topTwoShown() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            val firstIcon = mock<StatusBarIconView>()
            val secondIcon = mock<StatusBarIconView>()
            val thirdIcon = mock<StatusBarIconView>()
            setNotifs(
                listOf(
                    activeNotificationModel(key = "firstNotif", statusBarChipIcon = firstIcon),
                    activeNotificationModel(key = "secondNotif", statusBarChipIcon = secondIcon),
                    activeNotificationModel(key = "thirdNotif", statusBarChipIcon = thirdIcon),
                )
            )

            assertIsNotifChip(latest!!.primary, firstIcon)
            assertIsNotifChip(latest!!.secondary, secondIcon)
        }

    @Test
    fun chips_callAndNotifs_primaryIsCallSecondaryIsNotif() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            callRepo.setOngoingCallState(inCallModel(startTimeMs = 34))
            val firstIcon = mock<StatusBarIconView>()
            setNotifs(
                listOf(
                    activeNotificationModel(key = "firstNotif", statusBarChipIcon = firstIcon),
                    activeNotificationModel(
                        key = "secondNotif",
                        statusBarChipIcon = mock<StatusBarIconView>(),
                    ),
                )
            )

            assertIsCallChip(latest!!.primary)
            assertIsNotifChip(latest!!.secondary, firstIcon)
        }

    @Test
    fun chips_screenRecordAndCallAndNotifs_notifsNotShown() =
        testScope.runTest {
            val latest by collectLastValue(underTest.chips)

            callRepo.setOngoingCallState(inCallModel(startTimeMs = 34))
            screenRecordState.value = ScreenRecordModel.Recording
            setNotifs(
                listOf(
                    activeNotificationModel(
                        key = "notif",
                        statusBarChipIcon = mock<StatusBarIconView>(),
                    )
                )
            )

            assertIsScreenRecordChip(latest!!.primary)
            assertIsCallChip(latest!!.secondary)
        }

    @Test
    fun primaryChip_higherPriorityChipAdded_lowerPriorityChipReplaced() =
        testScope.runTest {
@@ -531,4 +629,12 @@ class OngoingActivityChipsWithRonsViewModelTest : SysuiTestCase() {
        assertThat((latest as OngoingActivityChipModel.Shown).icon)
            .isInstanceOf(OngoingActivityChipModel.ChipIcon.FullColorAppIcon::class.java)
    }

    private fun setNotifs(notifs: List<ActiveNotificationModel>) {
        activeNotificationListRepository.activeNotifications.value =
            ActiveNotificationsStore.Builder()
                .apply { notifs.forEach { addIndividualNotif(it) } }
                .build()
        testScope.runCurrent()
    }
}
+14 −19
Original line number Diff line number Diff line
@@ -45,31 +45,26 @@
            android:tint="?android:attr/colorPrimary"
        />

        <!-- Only one of [ongoing_activity_chip_time, ongoing_activity_chip_text] will ever
             be shown at one time. -->
        <!-- Only one of [ongoing_activity_chip_time, ongoing_activity_chip_text,
             ongoing_activity_chip_short_time_delta] will ever be shown at one time. -->

        <!-- Shows a timer, like 00:01. -->
        <com.android.systemui.statusbar.chips.ui.view.ChipChronometer
            android:id="@+id/ongoing_activity_chip_time"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:singleLine="true"
            android:gravity="center|start"
            android:paddingStart="@dimen/ongoing_activity_chip_icon_text_padding"
            android:textAppearance="@android:style/TextAppearance.Material.Small"
            android:fontFamily="@*android:string/config_headlineFontFamily"
            android:textColor="?android:attr/colorPrimary"
            style="@style/StatusBar.Chip.Text"
        />

        <!-- Used to show generic text in the chip instead of a timer. -->
        <!-- Shows generic text. -->
        <TextView
            android:id="@+id/ongoing_activity_chip_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:singleLine="true"
            android:gravity="center|start"
            android:paddingStart="@dimen/ongoing_activity_chip_icon_text_padding"
            android:textAppearance="@android:style/TextAppearance.Material.Small"
            android:fontFamily="@*android:string/config_headlineFontFamily"
            android:textColor="?android:attr/colorPrimary"
            style="@style/StatusBar.Chip.Text"
            android:visibility="gone"
            />

        <!-- Shows a time delta in short form, like "15min" or "1hr". -->
        <android.widget.DateTimeView
            android:id="@+id/ongoing_activity_chip_short_time_delta"
            style="@style/StatusBar.Chip.Text"
            android:visibility="gone"
            />

+14 −0
Original line number Diff line number Diff line
@@ -70,6 +70,20 @@
        <item name="android:fontWeight">700</item>
    </style>

    <style name="StatusBar" />
    <style name="StatusBar.Chip" />

    <style name="StatusBar.Chip.Text">
        <item name="android:layout_width">wrap_content</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:singleLine">true</item>
        <item name="android:gravity">center|start</item>
        <item name="android:paddingStart">@dimen/ongoing_activity_chip_icon_text_padding</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Material.Small</item>
        <item name="android:fontFamily">@*android:string/config_headlineFontFamily</item>
        <item name="android:textColor">?android:attr/colorPrimary</item>
    </style>

    <style name="Chipbar" />

    <style name="Chipbar.Text" parent="@*android:style/TextAppearance.DeviceDefault.Notification.Title">
+66 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

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

import com.android.systemui.dagger.SysUISingleton
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.flow.Flow
import kotlinx.coroutines.flow.map

/** A view model for status bar chips for promoted ongoing notifications. */
@SysUISingleton
class NotifChipsViewModel
@Inject
constructor(activeNotificationsInteractor: ActiveNotificationsInteractor) {
    /**
     * 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.
     */
    val chips: Flow<List<OngoingActivityChipModel.Shown>> =
        activeNotificationsInteractor.promotedOngoingNotifications.map { notifications ->
            notifications.mapNotNull { it.toChipModel() }
        }

    /**
     * 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? {
        // 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
        return OngoingActivityChipModel.Shown.ShortTimeDelta(
            icon,
            colors,
            time = this.whenTime,
            onClickListener,
        )
        // TODO(b/364653005): If Notification.showWhen = false, don't show the time delta.
        // TODO(b/364653005): If Notification.whenTime is in the past, show "ago" in the text.
        // TODO(b/364653005): If Notification.shortCriticalText is set, use that instead of `when`.
        // TODO(b/364653005): If the app that posted the notification is in the foreground, don't
        // show that app's chip.
    }
}
Loading