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

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

Merge "Add security info to dual shade toolbar" into main

parents bc5711b0 96e3f14a
Loading
Loading
Loading
Loading
+177 −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.ui.viewmodel.toolbar

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.Kosmos
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.fake
import com.android.systemui.qs.footer.domain.interactor.FakeFooterActionInteractor
import com.android.systemui.qs.footer.domain.model.SecurityButtonConfig
import com.android.systemui.qs.footerActionsInteractor
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
class ToolbarViewModelTest : SysuiTestCase() {
    private val kosmos =
        testKosmos().apply { footerActionsInteractor = FakeFooterActionInteractor() }

    private val Kosmos.underTest by Kosmos.Fixture { toolbarViewModelFactory.create() }

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

    @Test
    fun start_noSecurityInfo_collapsed() =
        with(kosmos) {
            runTest {
                assertThat(underTest.securityInfoViewModel).isNull()
                assertThat(underTest.securityInfoShowCollapsed).isTrue()
            }
        }

    @Test
    fun nullConfig_noSecurityInfo_collapsed() =
        with(kosmos) {
            runTest {
                setSecurityConfig(null)

                assertThat(underTest.securityInfoViewModel).isNull()
                assertThat(underTest.securityInfoShowCollapsed).isTrue()
            }
        }

    @Test
    fun config_notCollapsed() =
        with(kosmos) {
            runTest {
                setSecurityConfig(MANAGED_CONFIG)

                with(underTest.securityInfoViewModel!!) {
                    assertThat(icon).isEqualTo(MANAGED_CONFIG.icon)
                    assertThat(text).isEqualTo(MANAGED_CONFIG.text)
                    assertThat(onClick).isNotNull()
                }

                assertThat(underTest.securityInfoShowCollapsed).isFalse()
            }
        }

    @Test
    fun config_notCollapsed_beforeDelay() =
        with(kosmos) {
            runTest {
                setSecurityConfig(MANAGED_CONFIG)

                testScope.advanceTimeBy(COLLAPSED_DELAY - 100.milliseconds)

                assertThat(underTest.securityInfoShowCollapsed).isFalse()
            }
        }

    @Test
    fun config_collapsed_afterDelay() =
        with(kosmos) {
            runTest {
                setSecurityConfig(MANAGED_CONFIG)

                testScope.advanceTimeBy(COLLAPSED_DELAY + 100.milliseconds)

                assertThat(underTest.securityInfoShowCollapsed).isTrue()
            }
        }

    @Test
    fun changeConfig_timerRestartedForCollapsed() =
        with(kosmos) {
            runTest {
                setSecurityConfig(MANAGED_CONFIG)

                testScope.advanceTimeBy(COLLAPSED_DELAY - 2.seconds)

                setSecurityConfig(INFO_CONFIG)

                with(underTest.securityInfoViewModel!!) {
                    assertThat(icon).isEqualTo(INFO_CONFIG.icon)
                    assertThat(text).isEqualTo(INFO_CONFIG.text)
                    assertThat(onClick).isNull()
                }

                assertThat(underTest.securityInfoShowCollapsed).isFalse()

                testScope.advanceTimeBy(COLLAPSED_DELAY - 100.milliseconds)

                assertThat(underTest.securityInfoShowCollapsed).isFalse()
            }
        }

    @Test
    fun changeConfigToNull_collapsedAgainImmediately() =
        with(kosmos) {
            runTest {
                setSecurityConfig(MANAGED_CONFIG)

                testScope.advanceTimeBy(COLLAPSED_DELAY - 2.seconds)

                setSecurityConfig(null)

                assertThat(underTest.securityInfoShowCollapsed).isTrue()
            }
        }

    private fun Kosmos.setSecurityConfig(config: SecurityButtonConfig?) {
        footerActionsInteractor.fake.setSecurityConfig(config)
        runCurrent()
    }

    private companion object {
        val MANAGED_CONFIG =
            SecurityButtonConfig(
                icon = Icon.Resource(R.drawable.vd_work, null),
                text = "Managed device",
                isClickable = true,
            )

        val INFO_CONFIG =
            SecurityButtonConfig(
                icon = Icon.Resource(R.drawable.ic_info, null),
                text = "General information",
                isClickable = false,
            )

        private val COLLAPSED_DELAY = 5.seconds
    }
}
+101 −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.ui.compose.toolbar

import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.android.compose.animation.Expandable
import com.android.compose.animation.rememberExpandableController
import com.android.systemui.animation.Expandable
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel
import com.android.systemui.qs.ui.compose.borderOnFocus

@Composable
fun SecurityInfo(
    viewModel: FooterActionsSecurityButtonViewModel?,
    showCollapsed: Boolean,
    modifier: Modifier = Modifier,
) {
    if (viewModel == null) {
        return
    }
    val onClick: ((Expandable) -> Unit)? =
        viewModel.onClick?.let { onClick ->
            val context = LocalContext.current
            { expandable -> onClick(context, expandable) }
        }
    CompositionLocalProvider(
        value = LocalContentColor provides MaterialTheme.colorScheme.onSurface
    ) {
        Expandable(
            controller =
                rememberExpandableController(color = Color.Transparent, shape = CircleShape),
            modifier =
                modifier
                    .padding(horizontal = 4.dp)
                    .borderOnFocus(color = MaterialTheme.colorScheme.secondary, CornerSize(0))
                    .semantics {
                        if (onClick != null) {
                            role = Role.Button
                        }
                    },
            onClick = onClick,
            useModifierBasedImplementation = true,
        ) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Icon(
                    icon = viewModel.icon,
                    modifier =
                        Modifier.minimumInteractiveComponentSize().size(24.dp).semantics {
                            if (showCollapsed) {
                                contentDescription = viewModel.text
                            }
                        },
                )
                if (!showCollapsed) {
                    Text(
                        text = viewModel.text,
                        maxLines = 1,
                        style = MaterialTheme.typography.labelLarge,
                        color = MaterialTheme.colorScheme.primary,
                        modifier = Modifier.basicMarquee(iterations = 1, initialDelayMillis = 1000),
                    )
                }
            }
        }
    }
}
+103 −32
Original line number Diff line number Diff line
@@ -16,13 +16,19 @@

package com.android.systemui.qs.panels.ui.compose.toolbar

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -31,21 +37,60 @@ import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.development.ui.compose.BuildNumber
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.qs.footer.ui.compose.IconButton
import com.android.systemui.qs.panels.ui.compose.toolbar.Toolbar.TransitionKeys.SecurityInfoKey
import com.android.systemui.qs.panels.ui.viewmodel.TextFeedbackViewModel
import com.android.systemui.qs.panels.ui.viewmodel.toolbar.ToolbarViewModel
import com.android.systemui.qs.ui.compose.borderOnFocus

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Toolbar(viewModel: ToolbarViewModel, modifier: Modifier = Modifier) {
    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
        viewModel.userSwitcherViewModel?.let {
        val securityInfoCollapsed = viewModel.securityInfoShowCollapsed

        SharedTransitionLayout(modifier = Modifier.weight(1f)) {
            AnimatedContent(
                targetState = securityInfoCollapsed,
                contentAlignment =
                    if (securityInfoCollapsed) {
                        Alignment.CenterStart
                    } else {
                        Alignment.Center
                    },
                label = "Toolbar.CollapsedSecurityInfo",
            ) { securityInfoCollapsed ->
                if (securityInfoCollapsed) {
                    StandardToolbarLayout(animatedContentScope = this@AnimatedContent, viewModel)
                } else {
                    SecurityInfo(
                        viewModel = viewModel.securityInfoViewModel,
                        showCollapsed = false,
                        modifier =
                            Modifier.sharedElement(
                                rememberSharedContentState(key = SecurityInfoKey),
                                animatedVisibilityScope = this@AnimatedContent,
                            ),
                    )
                }
            }
        }

        IconButton(
                it,
            { viewModel.powerButtonViewModel },
            useModifierBasedExpandable = true,
                Modifier.sysuiResTag("multi_user_switch"),
            Modifier.sysuiResTag("pm_lite").minimumInteractiveComponentSize(),
        )
    }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransitionScope.StandardToolbarLayout(
    animatedContentScope: AnimatedContentScope,
    viewModel: ToolbarViewModel,
    modifier: Modifier = Modifier,
) {
    Row(modifier) {
        // TODO(b/410843063): Support the tooltip in DualShade
        val editModeButtonViewModel =
            rememberViewModel("Toolbar") { viewModel.editModeButtonViewModelFactory.create() }
@@ -54,17 +99,42 @@ fun Toolbar(viewModel: ToolbarViewModel, modifier: Modifier = Modifier) {
        IconButton(
            viewModel.settingsButtonViewModel,
            useModifierBasedExpandable = true,
            Modifier.sysuiResTag("settings_button_container"),
            Modifier.sysuiResTag("settings_button_container").minimumInteractiveComponentSize(),
        )

        viewModel.userSwitcherViewModel?.let {
            IconButton(
                it,
                useModifierBasedExpandable = true,
                Modifier.sysuiResTag("multi_user_switch").minimumInteractiveComponentSize(),
            )
        }
        SecurityInfo(
            viewModel = viewModel.securityInfoViewModel,
            showCollapsed = true,
            modifier =
                Modifier.sharedElement(
                    rememberSharedContentState(key = SecurityInfoKey),
                    animatedVisibilityScope = animatedContentScope,
                ),
        )

        Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
        val context = LocalContext.current
        val textFeedbackViewModel =
                rememberViewModel("", context) {
            rememberViewModel("Toolbar.TextFeedbackViewModel", context) {
                viewModel.textFeedbackContentViewModelFactory.create(context)
            }

            if (textFeedbackViewModel.textFeedback != TextFeedbackViewModel.NoFeedback) {
        Box(modifier = Modifier.weight(1f)) {
            val hasTextFeedback =
                textFeedbackViewModel.textFeedback !is TextFeedbackViewModel.NoFeedback

            Crossfade(
                targetState = hasTextFeedback,
                modifier = Modifier.align(Alignment.Center),
                label = "Toolbar.ShowTextFeedback",
            ) { showTextFeedback ->
                if (showTextFeedback) {
                    TextFeedback(textFeedbackViewModel.textFeedback, Modifier.wrapContentSize())
                } else {
                    BuildNumber(
@@ -79,11 +149,12 @@ fun Toolbar(viewModel: ToolbarViewModel, modifier: Modifier = Modifier) {
                    )
                }
            }
        }
    }
}

        IconButton(
            { viewModel.powerButtonViewModel },
            useModifierBasedExpandable = true,
            Modifier.sysuiResTag("pm_lite"),
        )
private object Toolbar {
    object TransitionKeys {
        const val SecurityInfoKey = "SecurityInfo"
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -42,7 +42,7 @@ constructor(
) : ExclusiveActivatable() {
    private val hydrator = Hydrator("TextFeedbackViewModel.hydrator")

    val textFeedback by
    val textFeedback: TextFeedbackViewModel by
        hydrator.hydratedStateOf(
            traceName = "textFeedback",
            initialValue = TextFeedbackViewModel.NoFeedback,
+40 −0
Original line number Diff line number Diff line
@@ -30,7 +30,9 @@ import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.qs.footer.domain.interactor.FooterActionsInteractor
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsSecurityButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.powerButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.securityButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.settingsButtonViewModel
import com.android.systemui.qs.footer.ui.viewmodel.userSwitcherViewModel
import com.android.systemui.qs.panels.ui.viewmodel.TextFeedbackContentViewModel
@@ -40,8 +42,13 @@ import com.android.systemui.shade.domain.interactor.ShadeModeInteractor
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import javax.inject.Provider
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

class ToolbarViewModel
@@ -87,6 +94,18 @@ constructor(
                ),
        )

    var securityInfoViewModel: FooterActionsSecurityButtonViewModel? by mutableStateOf(null)
        private set

    /**
     * Whether the security info text should be shown. When this is `true`, only the icon should be
     * shown.
     *
     * If there's no security info to show, this will also be `true`.
     */
    var securityInfoShowCollapsed: Boolean by mutableStateOf(true)
        private set

    override suspend fun onActivated(): Nothing {
        coroutineScope {
            launch {
@@ -98,6 +117,17 @@ constructor(
                }
            }
            launch { hydrator.activate() }
            launch {
                footerActionsInteractor.securityButtonConfig
                    .map { it?.let { securityButtonViewModel(it, ::onSecurityButtonClicked) } }
                    .distinctUntilChanged()
                    .collectLatest {
                        securityInfoShowCollapsed = it == null
                        securityInfoViewModel = it
                        delay(COLLAPSED_SECURITY_INFO_DELAY)
                        securityInfoShowCollapsed = true
                    }
            }
            awaitCancellation()
        }
    }
@@ -120,8 +150,18 @@ constructor(
        falsingInteractor.runIfNotFalseTap { footerActionsInteractor.showSettings(expandable) }
    }

    fun onSecurityButtonClicked(quickSettingsContext: Context, expandable: Expandable) {
        falsingInteractor.runIfNotFalseTap {
            footerActionsInteractor.showDeviceMonitoringDialog(quickSettingsContext, expandable)
        }
    }

    @AssistedFactory
    interface Factory {
        fun create(): ToolbarViewModel
    }

    private companion object {
        val COLLAPSED_SECURITY_INFO_DELAY = 5.seconds
    }
}
Loading