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

Commit c05a93cb authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Notif] Don't re-compose chips if it's un-necessary.

Whenever `StatusBarNotificationChipsInteractor.allNotificationChips` has
a new value, `NotifChipsViewModel` will re-create all the chip models,
which is guaranteed to create new click listeners. This will cause the
chips to definitely re-compose. However, `NotifChipsViewModel` doesn't
always need to re-create the chip models:

 - If fields in `promotedContent` are changed but those fields aren't
   used by the status bar chips, we don't need to re-compose.
 - If the time of the notification changed but we aren't showing the
   time anyway, we don't need to re-compose.

This CL creates a new intermediate data model that only saves the
relevant fields so we don't re-compose the chips if we don't need to.

Fixes: 393456147
Bug: 372657935
Flag: android.app.ui_rich_ongoing
Test: Verify via logging that notification chips get re-composed less
often
Test: atest NotifChipsViewModelTest

Change-Id: I625893ff13b5d6e2c6d667fad15480737d8d5093
parent 177bd9fc
Loading
Loading
Loading
Loading
+91 −0
Original line number Diff line number Diff line
@@ -288,6 +288,97 @@ class NotifChipsViewModelTest : SysuiTestCase() {
            assertIsNotifKey(latest!![1], secondKey)
        }

    @Test
    fun chips_notifTimeAndSystemTimeBothUpdated_modelNotRecreated() =
        kosmos.runTest {
            val latest by collectLastValue(underTest.chips)

            val currentTime = 3.minutes.inWholeMilliseconds
            fakeSystemClock.setCurrentTimeMillis(currentTime)

            val oldPromotedContentBuilder =
                PromotedNotificationContentBuilder("notif").applyToShared {
                    this.time = When.Time(currentTime)
                }
            setNotifs(
                listOf(
                    activeNotificationModel(
                        key = "notif",
                        statusBarChipIcon = createStatusBarIconViewOrNull(),
                        promotedContent = oldPromotedContentBuilder.build(),
                    )
                )
            )

            assertThat(latest).hasSize(1)
            assertThat(latest!![0]).isInstanceOf(OngoingActivityChipModel.Active::class.java)
            val oldModel = latest!![0]

            // WHEN the system time advances and the promoted content updates to that new time also
            val newTime = currentTime + 2.minutes.inWholeMilliseconds
            fakeSystemClock.setCurrentTimeMillis(newTime)
            val newPromotedContentBuilder =
                PromotedNotificationContentBuilder("notif").applyToShared {
                    this.time = When.Time(newTime)
                }
            setNotifs(
                listOf(
                    activeNotificationModel(
                        key = "notif",
                        statusBarChipIcon = createStatusBarIconViewOrNull(),
                        promotedContent = newPromotedContentBuilder.build(),
                    )
                )
            )

            // THEN we don't re-create the model because we still won't show the time
            assertThat(latest).hasSize(1)
            assertThat(latest!![0]).isSameInstanceAs(oldModel)
        }

    @Test
    fun chips_irrelevantPromotedContentUpdated_modelNotRecreated() =
        kosmos.runTest {
            val latest by collectLastValue(underTest.chips)

            val oldPromotedContentBuilder =
                PromotedNotificationContentBuilder("notif").applyToShared {
                    this.subText = "Old subtext"
                }
            setNotifs(
                listOf(
                    activeNotificationModel(
                        key = "notif",
                        statusBarChipIcon = createStatusBarIconViewOrNull(),
                        promotedContent = oldPromotedContentBuilder.build(),
                    )
                )
            )

            assertThat(latest).hasSize(1)
            assertThat(latest!![0]).isInstanceOf(OngoingActivityChipModel.Active::class.java)
            val oldModel = latest!![0]

            // WHEN promoted content updates with an irrelevant field
            val newPromotedContentBuilder =
                PromotedNotificationContentBuilder("notif").applyToShared {
                    this.subText = "New subtext"
                }
            setNotifs(
                listOf(
                    activeNotificationModel(
                        key = "notif",
                        statusBarChipIcon = createStatusBarIconViewOrNull(),
                        promotedContent = newPromotedContentBuilder.build(),
                    )
                )
            )

            // THEN we don't re-create the model
            assertThat(latest).hasSize(1)
            assertThat(latest!![0]).isSameInstanceAs(oldModel)
        }

    @Test
    fun chips_appStartsAsVisible_isHiddenTrue() =
        kosmos.runTest {
+109 −57
Original line number Diff line number Diff line
@@ -18,12 +18,14 @@ package com.android.systemui.statusbar.chips.notification.ui.viewmodel

import android.content.Context
import android.view.View
import com.android.internal.logging.InstanceId
import com.android.systemui.Flags
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.res.R
import com.android.systemui.statusbar.StatusBarIconView
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.ui.model.ColorsModel
@@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

/** A view model for status bar chips for promoted ongoing notifications. */
@@ -54,13 +57,62 @@ constructor(
    headsUpNotificationInteractor: HeadsUpNotificationInteractor,
    private val systemClock: SystemClock,
) {

    /**
     * A flow that prunes the incoming [NotificationChipModel] instances to just the information
     * each status bar chip needs.
     */
    private val notificationChipsWithPrunedContent: Flow<List<PrunedNotificationChipModel>> =
        notifChipsInteractor.allNotificationChips
            .map { chips -> chips.map { it.toPrunedModel() } }
            .distinctUntilChanged()

    private fun NotificationChipModel.toPrunedModel(): PrunedNotificationChipModel {
        // Chips are never shown when locked, so it's safe to use the version with sensitive content
        val content = promotedContent.privateVersion

        val time =
            when (val rawTime = content.time) {
                null -> null
                is PromotedNotificationContentModel.When.Time -> {
                    if (
                        rawTime.currentTimeMillis >=
                            systemClock.currentTimeMillis() + FUTURE_TIME_THRESHOLD_MILLIS
                    ) {
                        rawTime
                    } else {
                        // Don't show a `when` time that's close to now or in the past because it's
                        // likely that the app didn't intentionally set the `when` time to be shown
                        // in the status bar chip.
                        // TODO(b/393369213): If a notification sets a `when` time in the future and
                        // then that time comes and goes, the chip *will* start showing times in the
                        // past. Not going to fix this right now because the Compose implementation
                        // automatically handles this for us and we're hoping to launch the
                        // notification chips at the same time as the Compose chips.
                        null
                    }
                }
                is PromotedNotificationContentModel.When.Chronometer -> rawTime
            }
        return PrunedNotificationChipModel(
            key = key,
            appName = appName,
            statusBarChipIconView = statusBarChipIconView,
            text = content.shortCriticalText,
            time = time,
            wasPromotedAutomatically = content.wasPromotedAutomatically,
            isAppVisible = isAppVisible,
            instanceId = instanceId,
        )
    }

    /**
     * A flow modeling the current notification chips. Emits an empty list if there are no
     * notifications that are eligible to show a status bar chip.
     */
    val chips: Flow<List<OngoingActivityChipModel.Active>> =
        combine(
                notifChipsInteractor.allNotificationChips,
                notificationChipsWithPrunedContent,
                headsUpNotificationInteractor.statusBarHeadsUpState,
            ) { notifications, headsUpState ->
                notifications.map { it.toActivityChipModel(headsUpState) }
@@ -68,12 +120,11 @@ constructor(
            .distinctUntilChanged()

    /** Converts the notification to the [OngoingActivityChipModel] object. */
    private fun NotificationChipModel.toActivityChipModel(
    private fun PrunedNotificationChipModel.toActivityChipModel(
        headsUpState: TopPinnedState
    ): OngoingActivityChipModel.Active {
        PromotedNotificationUi.unsafeAssertInNewMode()
        // Chips are never shown when locked, so it's safe to use the version with sensitive content
        val chipContent = promotedContent.privateVersion

        val contentDescription = getContentDescription(this.appName)
        val icon =
            if (this.statusBarChipIconView != null) {
@@ -131,12 +182,12 @@ constructor(
            )
        }

        if (chipContent.shortCriticalText != null) {
        if (text != null) {
            return OngoingActivityChipModel.Active.Text(
                key = this.key,
                icon = icon,
                colors = colors,
                text = chipContent.shortCriticalText,
                text = text,
                onClickListenerLegacy = onClickListenerLegacy,
                clickBehavior = clickBehavior,
                isHidden = isHidden,
@@ -144,11 +195,11 @@ constructor(
            )
        }

        if (Flags.promoteNotificationsAutomatically() && chipContent.wasPromotedAutomatically) {
        if (Flags.promoteNotificationsAutomatically() && wasPromotedAutomatically) {
            // When we're promoting notifications automatically, the `when` time set on the
            // notification will likely just be set to the current time, which would cause the chip
            // to always show "now". We don't want early testers to get that experience since it's
            // not what will happen at launch, so just don't show any time.onometerstate
            // not what will happen at launch, so just don't show any time.
            return OngoingActivityChipModel.Active.IconOnly(
                key = this.key,
                icon = icon,
@@ -160,8 +211,9 @@ constructor(
            )
        }

        if (chipContent.time == null) {
            return OngoingActivityChipModel.Active.IconOnly(
        return when (time) {
            null -> {
                OngoingActivityChipModel.Active.IconOnly(
                    key = this.key,
                    icon = icon,
                    colors = colors,
@@ -171,50 +223,25 @@ constructor(
                    instanceId = instanceId,
                )
            }

        when (chipContent.time) {
            is PromotedNotificationContentModel.When.Time -> {
                return if (
                    chipContent.time.currentTimeMillis >=
                        systemClock.currentTimeMillis() + FUTURE_TIME_THRESHOLD_MILLIS
                ) {
                OngoingActivityChipModel.Active.ShortTimeDelta(
                    key = this.key,
                    icon = icon,
                    colors = colors,
                        time = chipContent.time.currentTimeMillis,
                    time = time.currentTimeMillis,
                    onClickListenerLegacy = onClickListenerLegacy,
                    clickBehavior = clickBehavior,
                    isHidden = isHidden,
                    instanceId = instanceId,
                )
                } else {
                    // Don't show a `when` time that's close to now or in the past because it's
                    // likely that the app didn't intentionally set the `when` time to be shown in
                    // the status bar chip.
                    // TODO(b/393369213): If a notification sets a `when` time in the future and
                    // then that time comes and goes, the chip *will* start showing times in the
                    // past. Not going to fix this right now because the Compose implementation
                    // automatically handles this for us and we're hoping to launch the notification
                    // chips at the same time as the Compose chips.
                    return OngoingActivityChipModel.Active.IconOnly(
                        key = this.key,
                        icon = icon,
                        colors = colors,
                        onClickListenerLegacy = onClickListenerLegacy,
                        clickBehavior = clickBehavior,
                        isHidden = isHidden,
                        instanceId = instanceId,
                    )
                }
            }
            is PromotedNotificationContentModel.When.Chronometer -> {
                return OngoingActivityChipModel.Active.Timer(
                OngoingActivityChipModel.Active.Timer(
                    key = this.key,
                    icon = icon,
                    colors = colors,
                    startTimeMs = chipContent.time.elapsedRealtimeMillis,
                    isEventInFuture = chipContent.time.isCountDown,
                    startTimeMs = time.elapsedRealtimeMillis,
                    isEventInFuture = time.isCountDown,
                    onClickListenerLegacy = onClickListenerLegacy,
                    clickBehavior = clickBehavior,
                    isHidden = isHidden,
@@ -236,6 +263,31 @@ constructor(
        )
    }

    /**
     * Model that prunes data from [NotificationChipModel] to just the information the status bar
     * chip needs.
     *
     * Used so that we don't re-create the chip [OngoingActivityChipModel] classes with new click
     * listeners unless absolutely necessary, which helps the chips re-compose less frequently. See
     * b/393456147.
     */
    private data class PrunedNotificationChipModel(
        val key: String,
        val appName: String,
        val statusBarChipIconView: StatusBarIconView?,
        /**
         * The text to show in the chip, or null if text shouldn't be shown. Text takes precedence
         * over [time].
         */
        val text: String?,
        /** The time to show in the chip, or null if the time shouldn't be shown. */
        val time: PromotedNotificationContentModel.When?,
        /** See [PromotedNotificationContentModel.wasPromotedAutomatically]. */
        val wasPromotedAutomatically: Boolean,
        val isAppVisible: Boolean,
        val instanceId: InstanceId?,
    )

    companion object {
        /**
         * Notifications must have a `when` time of at least 1 minute in the future in order for the