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

Commit 1a2e3cb4 authored by Steve Elliott's avatar Steve Elliott
Browse files

Bundle + summarization onboarding affordances

Flag: android.app.nm_summarization_onboarding_ui
Flag: com.android.systemui.notification_bundle_ui
Bug: 409748812
Bug: 391568054
Test: manual
Change-Id: I7e6bcff21efff7d68a1e395a7b4b5cc01b18b556
parent 8daf35d8
Loading
Loading
Loading
Loading
+17 −1
Original line number Diff line number Diff line
@@ -33,11 +33,13 @@ import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.currentValue
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.OnboardingAffordanceManager
import com.android.systemui.statusbar.notification.collection.BundleEntry
import com.android.systemui.statusbar.notification.collection.GroupEntry
import com.android.systemui.statusbar.notification.collection.InternalNotificationsApi
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder
import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider
import com.android.systemui.statusbar.notification.collection.render.BundleBarn
import com.android.systemui.statusbar.notification.collection.render.NodeController
import com.android.systemui.statusbar.notification.row.data.model.AppData
@@ -47,6 +49,9 @@ import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
import com.android.systemui.util.time.SystemClock
import com.google.common.truth.Truth.assertThat
import kotlin.test.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@@ -55,7 +60,11 @@ import org.mockito.Mock
import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations

@OptIn(ExperimentalMaterial3ExpressiveApi::class, InternalNotificationsApi::class)
@OptIn(
    ExperimentalMaterial3ExpressiveApi::class,
    InternalNotificationsApi::class,
    ExperimentalCoroutinesApi::class,
)
@SmallTest
@RunWith(AndroidJUnit4::class)
@TestableLooper.RunWithLooper
@@ -66,6 +75,11 @@ class BundleCoordinatorTest : SysuiTestCase() {
    @Mock private lateinit var promoController: NodeController
    @Mock private lateinit var bundleBarn: BundleBarn
    @Mock private lateinit var systemClock: SystemClock
    @Mock private lateinit var sectionHeaderVisProvider: SectionHeaderVisibilityProvider

    private val onboardingMgr by lazy {
        OnboardingAffordanceManager("test bundle onboarding", sectionHeaderVisProvider)
    }

    private lateinit var coordinator: BundleCoordinator

@@ -86,6 +100,8 @@ class BundleCoordinatorTest : SysuiTestCase() {
                promoController,
                bundleBarn,
                systemClock,
                TestScope(UnconfinedTestDispatcher()),
                onboardingMgr,
            )
    }

+131 −168
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.statusbar.notification.NotificationSectionsFeatureManager
import com.android.systemui.statusbar.notification.OnboardingAffordanceManager
import com.android.systemui.statusbar.notification.collection.GroupEntry
import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder
import com.android.systemui.statusbar.notification.collection.ListEntry
@@ -54,6 +55,11 @@ class NodeSpecBuilderTest : SysuiTestCase() {
    private val bundleBarn: BundleBarn = mock()
    private val logger = NodeSpecBuilderLogger(mock(), logcatLogBuffer())

    private val bundleOnboardingMgr =
        OnboardingAffordanceManager("bundles", sectionHeaderVisibilityProvider)
    private val summaryOnboardingMgr =
        OnboardingAffordanceManager("summarization", sectionHeaderVisibilityProvider)

    private var rootController: NodeController = buildFakeController("rootController")
    private var headerController0: NodeController = buildFakeController("header0")
    private var headerController1: NodeController = buildFakeController("header1")
@@ -80,27 +86,31 @@ class NodeSpecBuilderTest : SysuiTestCase() {
        whenever(viewBarn.requireNodeController(any())).thenAnswer {
            fakeViewBarn.getViewByEntry(it.getArgument(0))
        }

        specBuilder = NodeSpecBuilder(mediaContainerController, sectionsFeatureManager,
                sectionHeaderVisibilityProvider, viewBarn, bundleBarn, logger)
        specBuilder =
            NodeSpecBuilder(
                mediaContainerController,
                sectionsFeatureManager,
                sectionHeaderVisibilityProvider,
                viewBarn,
                bundleBarn,
                logger,
                bundleOnboardingMgr,
                summaryOnboardingMgr,
            )
    }

    @Test
    fun testMultipleSectionsWithSameController() {
        whenever(sectionHeaderVisibilityProvider.sectionHeadersVisible).thenReturn(true)
        checkOutput(
                listOf(
                        notif(0, section0),
                        notif(1, section2),
                        notif(2, section3)
                ),
            listOf(notif(0, section0), notif(1, section2), notif(2, section3)),
            tree(
                node(headerController0),
                notifNode(0),
                node(headerController2),
                notifNode(1),
                        notifNode(2)
                )
                notifNode(2),
            ),
        )
    }

@@ -108,13 +118,8 @@ class NodeSpecBuilderTest : SysuiTestCase() {
    fun testMultipleSectionsWithSameControllerNonConsecutive() {
        whenever(sectionHeaderVisibilityProvider.sectionHeadersVisible).thenReturn(true)
        checkOutput(
                listOf(
                        notif(0, section0),
                        notif(1, section1),
                        notif(2, section3),
                        notif(3, section1)
                ),
                tree()
            listOf(notif(0, section0), notif(1, section1), notif(2, section3), notif(3, section1)),
            tree(),
        )
    }

@@ -127,16 +132,11 @@ class NodeSpecBuilderTest : SysuiTestCase() {
                notif(0, section0NoHeader),
                notif(1, section0NoHeader),
                notif(2, section0NoHeader),
                notif(3, section0NoHeader)
                notif(3, section0NoHeader),
            ),

            // THEN we output a similarly simple flag list of nodes
            tree(
                notifNode(0),
                notifNode(1),
                notifNode(2),
                notifNode(3)
            )
            tree(notifNode(0), notifNode(1), notifNode(2), notifNode(3)),
        )
    }

@@ -152,7 +152,7 @@ class NodeSpecBuilderTest : SysuiTestCase() {
                notif(0, section0NoHeader),
                notif(1, section0NoHeader),
                notif(2, section0NoHeader),
                        notif(3, section0NoHeader)
                notif(3, section0NoHeader),
            ),

            // THEN we output a similarly simple flag list of nodes, with media at the top
@@ -161,8 +161,8 @@ class NodeSpecBuilderTest : SysuiTestCase() {
                notifNode(0),
                notifNode(1),
                notifNode(2),
                        notifNode(3)
                )
                notifNode(3),
            ),
        )
    }

@@ -172,12 +172,7 @@ class NodeSpecBuilderTest : SysuiTestCase() {
        whenever(sectionHeaderVisibilityProvider.sectionHeadersVisible).thenReturn(true)
        checkOutput(
            // GIVEN a flat list of notifications, spread across three sections
                listOf(
                        notif(0, section0),
                        notif(1, section0),
                        notif(2, section1),
                        notif(3, section2)
                ),
            listOf(notif(0, section0), notif(1, section0), notif(2, section1), notif(3, section2)),

            // THEN each section has its header injected
            tree(
@@ -187,8 +182,8 @@ class NodeSpecBuilderTest : SysuiTestCase() {
                node(headerController1),
                notifNode(2),
                node(headerController2),
                        notifNode(3)
                )
                notifNode(3),
            ),
        )
    }

@@ -198,20 +193,10 @@ class NodeSpecBuilderTest : SysuiTestCase() {
        whenever(sectionHeaderVisibilityProvider.sectionHeadersVisible).thenReturn(false)
        checkOutput(
            // GIVEN a flat list of notifications, spread across three sections
                listOf(
                        notif(0, section0),
                        notif(1, section0),
                        notif(2, section1),
                        notif(3, section2)
                ),
            listOf(notif(0, section0), notif(1, section0), notif(2, section1), notif(3, section2)),

            // THEN each section has its header injected
                tree(
                        notifNode(0),
                        notifNode(1),
                        notifNode(2),
                        notifNode(3)
                )
            tree(notifNode(0), notifNode(1), notifNode(2), notifNode(3)),
        )
    }

@@ -222,17 +207,9 @@ class NodeSpecBuilderTest : SysuiTestCase() {
            // GIVEN a mixed list of top-level notifications and groups
            listOf(
                notif(0, section0),
                    group(1, section1,
                            notif(2),
                            notif(3),
                            notif(4)
                    ),
                group(1, section1, notif(2), notif(3), notif(4)),
                notif(5, section2),
                    group(6, section2,
                            notif(7),
                            notif(8),
                            notif(9)
                    )
                group(6, section2, notif(7), notif(8), notif(9)),
            ),

            // THEN we properly construct all the nodes
@@ -240,19 +217,11 @@ class NodeSpecBuilderTest : SysuiTestCase() {
                node(headerController0),
                notifNode(0),
                node(headerController1),
                        notifNode(1,
                                notifNode(2),
                                notifNode(3),
                                notifNode(4)
                        ),
                notifNode(1, notifNode(2), notifNode(3), notifNode(4)),
                node(headerController2),
                notifNode(5),
                        notifNode(6,
                                notifNode(7),
                                notifNode(8),
                                notifNode(9)
                        )
                )
                notifNode(6, notifNode(7), notifNode(8), notifNode(9)),
            ),
        )
    }

@@ -264,11 +233,8 @@ class NodeSpecBuilderTest : SysuiTestCase() {
            listOf(
                notif(0, section0),
                notif(1, section1NoHeader),
                        group(2, section1NoHeader,
                                notif(3),
                                notif(4)
                        ),
                        notif(5, section2)
                group(2, section1NoHeader, notif(3), notif(4)),
                notif(5, section2),
            ),

            // THEN the header view is left out of the tree (but the notifs are still present)
@@ -276,13 +242,10 @@ class NodeSpecBuilderTest : SysuiTestCase() {
                node(headerController0),
                notifNode(0),
                notifNode(1),
                        notifNode(2,
                                notifNode(3),
                                notifNode(4)
                        ),
                notifNode(2, notifNode(3), notifNode(4)),
                node(headerController2),
                        notifNode(5)
                )
                notifNode(5),
            ),
        )
    }

@@ -291,14 +254,10 @@ class NodeSpecBuilderTest : SysuiTestCase() {
        whenever(sectionHeaderVisibilityProvider.sectionHeadersVisible).thenReturn(true)
        checkOutput(
            // GIVEN a malformed list where sections are not contiguous
                listOf(
                        notif(0, section0),
                        notif(1, section1),
                        notif(2, section0)
                ),
            listOf(notif(0, section0), notif(1, section1), notif(2, section0)),

            // THEN an exception is thrown
                tree()
            tree(),
        )
    }

@@ -310,29 +269,32 @@ class NodeSpecBuilderTest : SysuiTestCase() {
        try {
            checkNode(desiredTree, actualTree)
        } catch (e: AssertionError) {
            throw AssertionError("Trees don't match: ${e.message}\nActual tree:\n" +
                    treeSpecToStr(actualTree))
            throw AssertionError(
                "Trees don't match: ${e.message}\nActual tree:\n" + treeSpecToStr(actualTree)
            )
        }
    }

    private fun checkNode(desiredTree: NodeSpec, actualTree: NodeSpec) {
        if (actualTree.controller != desiredTree.controller) {
            throw AssertionError("Node {${actualTree.controller.nodeLabel}} should " +
                    "be ${desiredTree.controller.nodeLabel}")
            throw AssertionError(
                "Node {${actualTree.controller.nodeLabel}} should " +
                    "be ${desiredTree.controller.nodeLabel}"
            )
        }
        for (i in 0 until desiredTree.children.size) {
            if (i >= actualTree.children.size) {
                throw AssertionError("Node {${actualTree.controller.nodeLabel}}" +
                        " is missing child ${desiredTree.children[i].controller.nodeLabel}")
                throw AssertionError(
                    "Node {${actualTree.controller.nodeLabel}}" +
                        " is missing child ${desiredTree.children[i].controller.nodeLabel}"
                )
            }
            checkNode(desiredTree.children[i], actualTree.children[i])
        }
    }

    private fun notif(id: Int, section: NotifSection? = null): NotificationEntry {
        val entry = NotificationEntryBuilder()
                .setId(id)
                .build()
        val entry = NotificationEntryBuilder().setId(id).build()
        if (section != null) {
            getAttachState(entry).section = section
        }
@@ -343,14 +305,12 @@ class NodeSpecBuilderTest : SysuiTestCase() {
    private fun group(
        id: Int,
        section: NotifSection,
        vararg children: NotificationEntry
        vararg children: NotificationEntry,
    ): GroupEntry {
        val group = GroupEntryBuilder()
        val group =
            GroupEntryBuilder()
                .setKey("group_$id")
                .setSummary(
                        NotificationEntryBuilder()
                                .setId(id)
                                .build())
                .setSummary(NotificationEntryBuilder().setId(id).build())
                .setChildren(children.asList())
                .build()
        getAttachState(group).section = section
@@ -407,9 +367,10 @@ private fun buildFakeController(name: String): NodeController {
private fun buildSection(
    index: Int,
    @PriorityBucket bucket: Int,
    nodeController: NodeController?
    nodeController: NodeController?,
): NotifSection {
    return NotifSection(object : NotifSectioner("Section $index (bucket=$bucket)", bucket) {
    return NotifSection(
        object : NotifSectioner("Section $index (bucket=$bucket)", bucket) {

            override fun isInSection(entry: PipelineEntry?): Boolean {
                throw NotImplementedError("This should never be called")
@@ -418,5 +379,7 @@ private fun buildSection(
            override fun getHeaderNodeController(): NodeController? {
                return nodeController
            }
    }, index)
        },
        index,
    )
}
+44 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.domain.interactor

import android.content.Context
import android.content.SharedPreferences
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.settings.UserFileManager
import com.android.systemui.user.domain.interactor.SelectedUserInteractor
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map

class SharedPreferencesInteractor
@Inject
constructor(
    private val userFileManager: UserFileManager,
    private val userInteractor: SelectedUserInteractor,
    @Background private val bgDispatcher: CoroutineDispatcher,
) {
    fun sharedPreferences(
        fileName: String,
        @Context.PreferencesMode mode: Int,
    ): Flow<SharedPreferences> =
        userInteractor.selectedUser
            .map { userFileManager.getSharedPreferences(fileName, mode, it) }
            .flowOn(bgDispatcher)
}
+74 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.notification

import android.view.View
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider
import com.android.systemui.statusbar.notification.collection.render.NodeController
import com.android.systemui.statusbar.notification.stack.OnboardingAffordanceView
import dagger.Module
import dagger.Provides
import javax.inject.Qualifier
import kotlinx.coroutines.flow.MutableStateFlow

class OnboardingAffordanceManager(
    label: String,
    private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider,
) {
    val view = MutableStateFlow<OnboardingAffordanceView?>(null)

    val addAffordanceToStack: Boolean
        get() = view.value != null && sectionHeaderVisibilityProvider.sectionHeadersVisible

    val controller: NodeController =
        object : NodeController {
            override val nodeLabel: String
                get() = label

            override val view: View
                get() = this@OnboardingAffordanceManager.view.value!!

            override fun offerToKeepInParentForAnimation(): Boolean = false

            override fun removeFromParentIfKeptForAnimation(): Boolean = false

            override fun resetKeepInParentForAnimation() {}
        }
}

@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Summarization

@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Bundles

@Module
object NotificationOnboardingAffordanceManagerModule {
    @Provides
    @SysUISingleton
    @Summarization
    fun provideSummaryAffordanceManager(headerVisProvider: SectionHeaderVisibilityProvider) =
        OnboardingAffordanceManager(SUMMARY_ONBOARDING_LABEL, headerVisProvider)

    @Provides
    @SysUISingleton
    @Bundles
    fun provideBundleAffordanceManager(headerVisProvider: SectionHeaderVisibilityProvider) =
        OnboardingAffordanceManager(BUNDLE_ONBOARDING_LABEL, headerVisProvider)
}

private const val BUNDLE_ONBOARDING_LABEL = "bundle onboarding"
private const val SUMMARY_ONBOARDING_LABEL = "summary onboarding"
+20 −0
Original line number Diff line number Diff line
@@ -23,7 +23,10 @@ import android.app.NotificationChannel.SOCIAL_MEDIA_ID
import android.os.Build
import android.os.SystemProperties
import androidx.annotation.VisibleForTesting
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.Bundles
import com.android.systemui.statusbar.notification.OnboardingAffordanceManager
import com.android.systemui.statusbar.notification.collection.BundleEntry
import com.android.systemui.statusbar.notification.collection.BundleSpec
import com.android.systemui.statusbar.notification.collection.GroupEntry
@@ -33,6 +36,7 @@ import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.PipelineEntry
import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeRenderListListener
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.Invalidator
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifBundler
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner
@@ -50,6 +54,9 @@ import com.android.systemui.statusbar.notification.stack.BUCKET_RECS
import com.android.systemui.statusbar.notification.stack.BUCKET_SOCIAL
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

/** Coordinator for sections derived from NotificationAssistantService classification. */
@CoordinatorScope
@@ -62,6 +69,8 @@ constructor(
    @PromoHeader private val promoHeaderController: NodeController,
    private val bundleBarn: BundleBarn,
    private val systemClock: SystemClock,
    @Application private val coroutineScope: CoroutineScope,
    @Bundles private val onboardingAffordanceManager: OnboardingAffordanceManager,
) : Coordinator {

    val newsSectioner =
@@ -328,6 +337,17 @@ constructor(
            pipeline.addOnBeforeRenderListListener(bundleCountUpdater)
            pipeline.addOnBeforeRenderListListener(bundleMembershipUpdater)
            pipeline.addOnBeforeRenderListListener(bundleAppDataUpdater)
            bindOnboardingAffordanceInvalidator(pipeline)
        }
    }

    private fun bindOnboardingAffordanceInvalidator(pipeline: NotifPipeline) {
        val invalidator = object : Invalidator("bundle onboarding") {}
        pipeline.addPreRenderInvalidator(invalidator)
        coroutineScope.launch {
            onboardingAffordanceManager.view.collect {
                invalidator.invalidateList("bundle onboarding view changed")
            }
        }
    }

Loading