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

Commit 29c55f31 authored by Fabian Kozynski's avatar Fabian Kozynski Committed by Android (Google) Code Review
Browse files

Merge "Text feedback when clicking on small toggle tiles" into main

parents 73bd4d6d 7eba77e6
Loading
Loading
Loading
Loading
+33 −2
Original line number Diff line number Diff line
@@ -87,6 +87,8 @@ import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsForegroundServicesButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
import com.android.systemui.qs.panels.ui.compose.toolbar.TextFeedback.tag
import com.android.systemui.qs.panels.ui.viewmodel.TextFeedbackViewModel
import com.android.systemui.qs.ui.composable.QuickSettings
import com.android.systemui.qs.ui.composable.QuickSettingsTheme
import com.android.systemui.qs.ui.compose.borderOnFocus
@@ -145,6 +147,10 @@ fun FooterActions(
    var userSwitcher by remember { mutableStateOf<FooterActionsButtonViewModel?>(null) }
    var power by remember { mutableStateOf(viewModel.initialPower()) }

    var textFeedback by remember {
        mutableStateOf<TextFeedbackViewModel>(TextFeedbackViewModel.NoFeedback)
    }

    LaunchedEffect(
        context,
        qsVisibilityLifecycleOwner,
@@ -152,6 +158,7 @@ fun FooterActions(
        viewModel.security,
        viewModel.foregroundServices,
        viewModel.userSwitcher,
        viewModel.textFeedback,
    ) {
        launch {
            // Listen for dialog requests as soon as we are composed, even when not visible.
@@ -164,6 +171,7 @@ fun FooterActions(
            launch { viewModel.foregroundServices.collect { foregroundServices = it } }
            launch { viewModel.userSwitcher.collect { userSwitcher = it } }
            launch { viewModel.power.collect { power = it } }
            launch { viewModel.textFeedback.collect { textFeedback = it } }
        }
    }

@@ -215,12 +223,20 @@ fun FooterActions(
        verticalAlignment = Alignment.CenterVertically,
    ) {
        CompositionLocalProvider(LocalContentColor provides contentColor) {
            if (security == null && foregroundServices == null) {
            if (
                security == null &&
                    foregroundServices == null &&
                    textFeedback == TextFeedbackViewModel.NoFeedback
            ) {
                Spacer(Modifier.weight(1f))
            }

            val useModifierBasedExpandable = remember { QSComposeFragment.isEnabled }
            if (textFeedback != TextFeedbackViewModel.NoFeedback) {
                TextFeedback({ textFeedback }, Modifier.weight(1f))
            } else {
                SecurityButton({ security }, useModifierBasedExpandable, Modifier.weight(1f))
            }
            ForegroundServicesButton({ foregroundServices }, useModifierBasedExpandable)
            IconButton(
                { userSwitcher },
@@ -261,6 +277,21 @@ private fun SecurityButton(
    )
}

@Composable
private fun TextFeedback(model: () -> TextFeedbackViewModel, modifier: Modifier = Modifier) {
    val model = model()
    if (model is TextFeedbackViewModel.LoadedTextFeedback) {
        TextButton(
            model.icon,
            model.label,
            showNewDot = false,
            useModifierBasedExpandable = false,
            onClick = null,
            modifier = modifier.tag(),
        )
    }
}

/** The foreground services button. */
@Composable
private fun RowScope.ForegroundServicesButton(
+11 −2
Original line number Diff line number Diff line
@@ -39,7 +39,8 @@ import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.nullable
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -53,11 +54,19 @@ import org.mockito.kotlin.eq
@RunWith(AndroidJUnit4::class)
@TestableLooper.RunWithLooper
class FooterActionsInteractorTest : SysuiTestCase() {
    private val testDispatcher = StandardTestDispatcher()
    private val testScope = TestScope(testDispatcher)
    private lateinit var utils: FooterActionsTestUtils

    @Before
    fun setUp() {
        utils = FooterActionsTestUtils(context, TestableLooper.get(this), TestCoroutineScheduler())
        utils =
            FooterActionsTestUtils(
                context,
                TestableLooper.get(this),
                testScope.testScheduler,
                testScope.backgroundScope,
            )
    }

    @Test
+121 −1
Original line number Diff line number Diff line
@@ -18,25 +18,38 @@ package com.android.systemui.qs.footer.ui.viewmodel

import android.graphics.drawable.Drawable
import android.os.UserManager
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.testing.TestableLooper
import android.testing.TestableLooper.RunWithLooper
import android.view.ContextThemeWrapper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.testing.UiEventLoggerFake
import com.android.settingslib.Utils
import com.android.settingslib.drawable.UserIconDrawable
import com.android.systemui.InstanceIdSequenceFake
import com.android.systemui.SysuiTestCase
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.flags.DisableSceneContainer
import com.android.systemui.flags.EnableSceneContainer
import com.android.systemui.qs.FakeFgsManagerController
import com.android.systemui.qs.QSSecurityFooterUtils
import com.android.systemui.qs.QsEventLoggerFake
import com.android.systemui.qs.flags.QSComposeFragment
import com.android.systemui.qs.footer.FooterActionsTestUtils
import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig
import com.android.systemui.qs.panels.ui.viewmodel.TextFeedbackViewModel
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.qs.tiles.base.shared.model.FakeQSTileConfigProvider
import com.android.systemui.qs.tiles.base.shared.model.QSTileConfigProvider
import com.android.systemui.res.R
import com.android.systemui.security.data.model.SecurityModel
import com.android.systemui.shade.shared.model.ShadeMode
import com.android.systemui.statusbar.connectivity.ConnectivityModule
import com.android.systemui.statusbar.policy.FakeSecurityController
import com.android.systemui.statusbar.policy.FakeUserInfoController
import com.android.systemui.statusbar.policy.FakeUserInfoController.FakeInfo
@@ -72,7 +85,13 @@ class FooterActionsViewModelTest : SysuiTestCase() {

    @Before
    fun setUp() {
        utils = FooterActionsTestUtils(context, TestableLooper.get(this), testScope.testScheduler)
        utils =
            FooterActionsTestUtils(
                context,
                TestableLooper.get(this),
                testScope.testScheduler,
                testScope.backgroundScope,
            )
    }

    private fun runTest(block: suspend TestScope.() -> Unit) {
@@ -440,4 +459,105 @@ class FooterActionsViewModelTest : SysuiTestCase() {
        underTest.onQuickSettingsExpansionChanged(1f, isInSplitShade = false)
        assertThat(underTest.backgroundAlpha.value).isEqualTo(1f)
    }

    @Test
    @DisableFlags(QSComposeFragment.FLAG_NAME)
    @DisableSceneContainer
    fun textFeedback_neverFeedback() = runTest {
        val qsTileConfigProvider = createAndPopulateQsTileConfigProvider()
        val textFeedbackInteractor =
            utils.textFeedbackInteractor(qsTileConfigProvider = qsTileConfigProvider)
        val underTest =
            utils.footerActionsViewModel(
                textFeedbackInteractor = textFeedbackInteractor,
                shadeMode = ShadeMode.Single,
            )

        val textFeedback by collectLastValue(underTest.textFeedback)

        assertThat(textFeedback).isEqualTo(TextFeedbackViewModel.NoFeedback)

        textFeedbackInteractor.requestShowFeedback(AIRPLANE_MODE_TILE_SPEC)

        assertThat(textFeedback).isEqualTo(TextFeedbackViewModel.NoFeedback)
    }

    @Test
    @EnableFlags(QSComposeFragment.FLAG_NAME)
    fun textFeedback_composeFragmentEnabled() = runTest { textFeedback_newComposeUI() }

    @Test
    @EnableSceneContainer
    fun textFeedback_sceneContainerEnabled() = runTest { textFeedback_newComposeUI() }

    private fun TestScope.textFeedback_newComposeUI() {
        val qsTileConfigProvider = createAndPopulateQsTileConfigProvider()
        val textFeedbackInteractor =
            utils.textFeedbackInteractor(qsTileConfigProvider = qsTileConfigProvider)
        val securityController = FakeSecurityController()

        // We need Security so the combined flow will have at least one value
        val qsSecurityFooterUtils = mock<QSSecurityFooterUtils>()

        // Mock QSSecurityFooter to map a SecurityModel into a SecurityButtonConfig using the
        // logic in securityToConfig.
        val securityToConfig: (SecurityModel) -> SecurityButtonConfig? = { null }
        whenever(qsSecurityFooterUtils.getButtonConfig(any())).thenAnswer {
            securityToConfig(it.arguments.first() as SecurityModel)
        }
        val fgsManagerController =
            FakeFgsManagerController(showFooterDot = false, numRunningPackages = 1)

        val underTest =
            utils.footerActionsViewModel(
                textFeedbackInteractor = textFeedbackInteractor,
                footerActionsInteractor =
                    utils.footerActionsInteractor(
                        foregroundServicesRepository =
                            utils.foregroundServicesRepository(fgsManagerController),
                        securityRepository = utils.securityRepository(securityController),
                        qsSecurityFooterUtils = qsSecurityFooterUtils,
                    ),
                shadeMode = ShadeMode.Single,
            )

        val textFeedback by collectLastValue(underTest.textFeedback)
        val foregroundServices by collectLastValue(underTest.foregroundServices)

        assertThat(textFeedback).isEqualTo(TextFeedbackViewModel.NoFeedback)
        assertThat(foregroundServices!!.displayText).isTrue()

        textFeedbackInteractor.requestShowFeedback(AIRPLANE_MODE_TILE_SPEC)

        val config = qsTileConfigProvider.getConfig(AIRPLANE_MODE_TILE_SPEC.spec)
        assertThat(textFeedback)
            .isEqualTo(
                TextFeedbackViewModel.LoadedTextFeedback(
                    label = context.getString(config.uiConfig.labelRes),
                    icon =
                        Icon.Loaded(
                            drawable = context.getDrawable(config.uiConfig.iconRes)!!,
                            contentDescription = null,
                            res = config.uiConfig.iconRes,
                        ),
                )
            )
        assertThat(foregroundServices!!.displayText).isFalse()
    }

    companion object {
        val AIRPLANE_MODE_TILE_SPEC = TileSpec.create(ConnectivityModule.AIRPLANE_MODE_TILE_SPEC)

        private fun createAndPopulateQsTileConfigProvider(): QSTileConfigProvider {
            val logger =
                QsEventLoggerFake(UiEventLoggerFake(), InstanceIdSequenceFake(Int.MAX_VALUE))

            return FakeQSTileConfigProvider().apply {
                putConfig(
                    AIRPLANE_MODE_TILE_SPEC,
                    ConnectivityModule.provideAirplaneModeTileConfig(logger),
                )
            }
        }
    }
}
+96 −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.qs.panels.data.repository

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.qs.panels.data.model.TextFeedbackRequestModel
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.junit.Before
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class ToggleTextFeedbackRepositoryTest : SysuiTestCase() {
    private val kosmos = testKosmos()

    private val underTest = kosmos.toggleTextFeedbackRepository

    @Before
    fun setUp() {
        underTest.activateIn(kosmos.testScope)
    }

    @Test
    fun noFeedbackOnStart() =
        with(kosmos) {
            runTest {
                val feedbackRequest by collectLastValue(underTest.textFeedback)
                assertThat(feedbackRequest).isEqualTo(TextFeedbackRequestModel.NoFeedback)
            }
        }

    @Test
    fun feedbackRequestShown() =
        with(kosmos) {
            runTest {
                val feedbackRequest by collectLastValue(underTest.textFeedback)

                underTest.setTextFeedback(TILE_SPEC)

                assertThat(feedbackRequest)
                    .isEqualTo(TextFeedbackRequestModel.FeedbackForTile(TILE_SPEC))
            }
        }

    @Test
    fun feedbackRequest_noFeedbackIfNoCollectors_firstIsAlwaysNoFeedback() =
        with(kosmos) {
            runTest {
                val collectionJob =
                    testScope.backgroundScope.launch { underTest.textFeedback.collect {} }

                underTest.setTextFeedback(TILE_SPEC)
                runCurrent()

                collectionJob.cancelAndJoin()
                runCurrent()

                assertThat(underTest.textFeedback.first())
                    .isEqualTo(TextFeedbackRequestModel.NoFeedback)
            }
        }

    companion object {
        private val TILE_SPEC = TileSpec.create("a")
        private val OTHER_TILE_SPEC = TileSpec.create("b")
    }
}
+254 −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.qs.panels.domain.interactor

import android.content.ComponentName
import android.graphics.drawable.TestStubDrawable
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.qs.panels.domain.model.TextFeedbackModel
import com.android.systemui.qs.pipeline.data.repository.FakeInstalledTilesComponentRepository
import com.android.systemui.qs.pipeline.data.repository.fakeInstalledTilesRepository
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.qs.tiles.base.shared.model.QSTileConfig
import com.android.systemui.qs.tiles.base.shared.model.populateQsTileConfigProvider
import com.android.systemui.qs.tiles.impl.airplane.qsAirplaneModeTileConfig
import com.android.systemui.qs.tiles.impl.flashlight.qsFlashlightTileConfig
import com.android.systemui.settings.fakeUserTracker
import com.android.systemui.settings.userTracker
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.mock
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import org.junit.runner.RunWith
import org.mockito.kotlin.whenever

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class TextFeedbackInteractorTest : SysuiTestCase() {
    private val kosmos =
        testKosmos().apply {
            populateQsTileConfigProvider()
            whenever(fakeUserTracker.userContext.packageManager).thenReturn(mock())
        }

    private val underTest = kosmos.textFeedbackInteractor

    @Test
    fun noFeedback() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)

                assertThat(textFeedback).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    @Test
    fun knownTileTextFeedback() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(qsAirplaneModeTileConfig.tileSpec)
                runCurrent()

                assertThat(textFeedback).isEqualTo(qsAirplaneModeTileConfig.toTextFeedbackModel())
            }
        }

    @Test
    fun unknownTileTextFeedback_noFeedback() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(TileSpec.create("unknown"))
                runCurrent()

                assertThat(textFeedback).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    @Test
    fun customTile_notInstalled_NoFeedback() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(CUSTOM_TILE_SPEC)
                runCurrent()

                assertThat(textFeedback).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    @Test
    fun customTile_installed_loadedIconAndLabel() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)
                fakeInstalledTilesRepository.setInstalledServicesForUser(
                    userTracker.userId,
                    listOf(
                        FakeInstalledTilesComponentRepository.ServiceInfo(
                            CUSTOM_TILE_SPEC.componentName,
                            serviceName = SERVICE_NAME,
                            serviceIcon = SERVICE_ICON,
                        )
                    ),
                )
                runCurrent()

                underTest.requestShowFeedback(CUSTOM_TILE_SPEC)
                runCurrent()

                assertThat(textFeedback)
                    .isEqualTo(
                        TextFeedbackModel.LoadedTextFeedback(
                            label = SERVICE_NAME,
                            icon = Icon.Loaded(SERVICE_ICON, null),
                        )
                    )
            }
        }

    @Test
    fun customTile_installed_differentUser_noFeedback() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)
                fakeInstalledTilesRepository.setInstalledServicesForUser(
                    userTracker.userId + 1,
                    listOf(
                        FakeInstalledTilesComponentRepository.ServiceInfo(
                            CUSTOM_TILE_SPEC.componentName,
                            serviceName = SERVICE_NAME,
                            serviceIcon = SERVICE_ICON,
                        )
                    ),
                )
                runCurrent()

                underTest.requestShowFeedback(CUSTOM_TILE_SPEC)
                runCurrent()

                assertThat(textFeedback).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    @Test
    fun feedbackRequestRemainsVisibleForSomeTime() =
        with(kosmos) {
            runTest {
                val feedbackRequest by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(qsAirplaneModeTileConfig.tileSpec)
                runCurrent()

                testScope.advanceTimeBy(TextFeedbackInteractor.CLEAR_DELAY - 1.milliseconds)

                assertThat(feedbackRequest)
                    .isEqualTo(qsAirplaneModeTileConfig.toTextFeedbackModel())
            }
        }

    @Test
    fun feedbackRequest_afterClearDelay_noFeedback() =
        with(kosmos) {
            runTest {
                val feedbackRequest by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(qsAirplaneModeTileConfig.tileSpec)
                runCurrent()

                testScope.advanceTimeBy(TextFeedbackInteractor.CLEAR_DELAY + 1.milliseconds)
                runCurrent()

                assertThat(feedbackRequest).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    @Test
    fun feedbackRequest_thenAnotherFeedbackRequest_timerRestarted() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(qsAirplaneModeTileConfig.tileSpec)
                runCurrent()

                testScope.advanceTimeBy(TextFeedbackInteractor.CLEAR_DELAY - 1.milliseconds)
                runCurrent()

                underTest.requestShowFeedback(qsFlashlightTileConfig.tileSpec)

                assertThat(textFeedback).isEqualTo(qsFlashlightTileConfig.toTextFeedbackModel())

                testScope.advanceTimeBy(TextFeedbackInteractor.CLEAR_DELAY - 1.milliseconds)

                assertThat(textFeedback).isEqualTo(qsFlashlightTileConfig.toTextFeedbackModel())

                testScope.advanceTimeBy(2.milliseconds)

                assertThat(textFeedback).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    @Test
    fun feedbackRequest_thenSameTileRequest_timerRestarted() =
        with(kosmos) {
            runTest {
                val textFeedback by collectLastValue(underTest.textFeedback)

                underTest.requestShowFeedback(qsAirplaneModeTileConfig.tileSpec)
                runCurrent()

                testScope.advanceTimeBy(TextFeedbackInteractor.CLEAR_DELAY - 1.milliseconds)
                runCurrent()

                underTest.requestShowFeedback(qsAirplaneModeTileConfig.tileSpec)
                testScope.advanceTimeBy(TextFeedbackInteractor.CLEAR_DELAY - 1.milliseconds)

                assertThat(textFeedback).isEqualTo(qsAirplaneModeTileConfig.toTextFeedbackModel())

                testScope.advanceTimeBy(2.milliseconds)

                assertThat(textFeedback).isEqualTo(TextFeedbackModel.NoFeedback)
            }
        }

    companion object {
        private fun QSTileConfig.toTextFeedbackModel(): TextFeedbackModel.TextFeedback {
            return TextFeedbackModel.TextFeedback(uiConfig.labelRes, uiConfig.iconRes)
        }

        private val CUSTOM_TILE_SPEC = TileSpec.create(ComponentName("pkg", "srv"))
        private val SERVICE_NAME = "TileService"
        private val SERVICE_ICON = TestStubDrawable("tile_service_icon")
    }
}
Loading