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

Commit 963a3386 authored by Olivier St-Onge's avatar Olivier St-Onge Committed by Android (Google) Code Review
Browse files

Merge "Add content description to the stacked mobile icon" into main

parents c708eb47 9256daf2
Loading
Loading
Loading
Loading
+59 −0
Original line number Diff line number Diff line
@@ -113,6 +113,65 @@ class StackedMobileIconViewModelKairosTest : SysuiTestCase() {
            assertThat(underTest.dualSim!!.secondary.level).isEqualTo(1)
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_requiresBothIcons() =
        kosmos.runKairosTest {
            mobileConnectionsRepositoryKairos.fake.subscriptions.setValue(listOf())
            assertThat(underTest.contentDescription).isNull()

            mobileConnectionsRepositoryKairos.fake.subscriptions.setValue(listOf(SUB_1))
            assertThat(underTest.contentDescription).isNull()

            mobileConnectionsRepositoryKairos.fake.subscriptions.setValue(
                listOf(SUB_1, SUB_2, SUB_3)
            )
            assertThat(underTest.contentDescription).isNull()

            mobileConnectionsRepositoryKairos.fake.subscriptions.setValue(listOf(SUB_1, SUB_2))
            assertThat(underTest.contentDescription).isNotNull()
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_tracksBars() =
        kosmos.runKairosTest {
            mobileConnectionsRepositoryKairos.fake.subscriptions.setValue(listOf(SUB_1, SUB_2))
            setIconLevel(SUB_1.subscriptionId, 1)
            setIconLevel(SUB_2.subscriptionId, 2)

            assertThat(underTest.contentDescription!!)
                .isEqualTo("default name, one bar. default name, two bars.")

            // Change signal bars
            setIconLevel(SUB_1.subscriptionId, 3)
            setIconLevel(SUB_2.subscriptionId, 1)

            assertThat(underTest.contentDescription!!)
                .isEqualTo("default name, three bars. default name, one bar.")
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_hasActiveIconFirst() =
        kosmos.runKairosTest {
            // Active sub id is null, order is unchanged
            mobileConnectionsRepositoryKairos.fake.subscriptions.setValue(listOf(SUB_1, SUB_2))
            setIconLevel(SUB_1.subscriptionId, 1)
            setIconLevel(SUB_2.subscriptionId, 2)

            assertThat(underTest.contentDescription!!)
                .isEqualTo("default name, one bar. default name, two bars.")

            // Active sub is 2, order is swapped
            mobileConnectionsRepositoryKairos.fake.setActiveMobileDataSubscriptionId(
                SUB_2.subscriptionId
            )

            assertThat(underTest.contentDescription!!)
                .isEqualTo("default name, two bars. default name, one bar.")
        }

    private suspend fun KairosTestScope.setIconLevel(subId: Int, level: Int) {
        mobileConnectionsRepositoryKairos.fake.mobileConnectionsBySubId.sample()[subId]!!.apply {
            isNonTerrestrial.setValue(false)
+72 −4
Original line number Diff line number Diff line
@@ -43,15 +43,17 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class StackedMobileIconViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val testScope = kosmos.testScope

    private val Kosmos.underTest: StackedMobileIconViewModelImpl by Fixture {
        stackedMobileIconViewModelImpl
    }

    @Before
    fun setUp() {
        kosmos.underTest.activateIn(testScope)
    fun setUp() =
        kosmos.run {
            // Set prerequisites for the stacked icon
            fakeMobileIconsInteractor.isStackable.value = true
            underTest.activateIn(testScope)
        }

    @Test
@@ -109,6 +111,72 @@ class StackedMobileIconViewModelTest : SysuiTestCase() {
            assertThat(underTest.dualSim!!.secondary.level).isEqualTo(1)
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_requiresBothIcons() =
        kosmos.runTest {
            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf()
            assertThat(underTest.contentDescription).isNull()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1)
            assertThat(underTest.contentDescription).isNull()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2, SUB_3)
            assertThat(underTest.contentDescription).isNull()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2)
            assertThat(underTest.contentDescription).isNotNull()
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_tracksBars() =
        kosmos.runTest {
            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2)
            setIconLevel(SUB_1.subscriptionId, 1)
            setIconLevel(SUB_2.subscriptionId, 2)

            assertThat(underTest.contentDescription!!)
                .isEqualTo("demo mode, one bar. demo mode, two bars.")

            // Change signal bars
            setIconLevel(SUB_1.subscriptionId, 3)
            setIconLevel(SUB_2.subscriptionId, 1)

            assertThat(underTest.contentDescription!!)
                .isEqualTo("demo mode, three bars. demo mode, one bar.")
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_hasActiveIconFirst() =
        kosmos.runTest {
            // Active sub id is null, order is unchanged
            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2)
            setIconLevel(SUB_1.subscriptionId, 1)
            setIconLevel(SUB_2.subscriptionId, 2)

            assertThat(underTest.contentDescription!!)
                .isEqualTo("demo mode, one bar. demo mode, two bars.")

            // Active sub is 2, order is swapped
            fakeMobileIconsInteractor.activeMobileDataSubscriptionId.value = SUB_2.subscriptionId

            assertThat(underTest.contentDescription!!)
                .isEqualTo("demo mode, two bars. demo mode, one bar.")
        }

    @Test
    @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
    fun contentDescription_tracksVisibility() =
        kosmos.runTest {
            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2)
            assertThat(underTest.contentDescription).isNotNull()

            fakeMobileIconsInteractor.isStackable.value = false
            assertThat(underTest.contentDescription).isNull()
        }

    private fun setIconLevel(subId: Int, level: Int) {
        with(kosmos.fakeMobileIconsInteractor.getInteractorForSubId(subId)!!) {
            signalLevelIcon.value =
+67 −23
Original line number Diff line number Diff line
@@ -16,11 +16,12 @@

package com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel

import androidx.compose.runtime.derivedStateOf
import android.content.Context
import androidx.compose.runtime.getValue
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.shade.ShadeDisplayAware
import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.StackedMobileIconViewModel.DualSim
import dagger.assisted.AssistedFactory
@@ -30,9 +31,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map

interface StackedMobileIconViewModel {
    val dualSim: DualSim?
    val contentDescription: String?
    val networkTypeIcon: Icon.Resource?
    val isIconVisible: Boolean

@@ -45,17 +48,12 @@ interface StackedMobileIconViewModel {
@OptIn(ExperimentalCoroutinesApi::class)
class StackedMobileIconViewModelImpl
@AssistedInject
constructor(mobileIconsViewModel: MobileIconsViewModel) :
    ExclusiveActivatable(), StackedMobileIconViewModel {
constructor(
    mobileIconsViewModel: MobileIconsViewModel,
    @ShadeDisplayAware private val context: Context,
) : ExclusiveActivatable(), StackedMobileIconViewModel {
    private val hydrator = Hydrator("StackedMobileIconViewModel")

    private val isStackable: Boolean by
        hydrator.hydratedStateOf(
            traceName = "isStackable",
            source = mobileIconsViewModel.isStackable,
            initialValue = false,
        )

    private val iconViewModelFlow: Flow<List<MobileIconViewModelCommon>> =
        combine(
            mobileIconsViewModel.mobileSubViewModels,
@@ -65,10 +63,7 @@ constructor(mobileIconsViewModel: MobileIconsViewModel) :
            viewModels.sortedByDescending { it.subscriptionId == activeSubId }
        }

    override val dualSim: DualSim? by
        hydrator.hydratedStateOf(
            traceName = "dualSim",
            source =
    private val _dualSim: Flow<DualSim?> =
        iconViewModelFlow.flatMapLatest { viewModels ->
            combine(viewModels.map { it.icon }) { icons ->
                icons
@@ -77,7 +72,39 @@ constructor(mobileIconsViewModel: MobileIconsViewModel) :
                    .takeIf { it.size == 2 }
                    ?.let { DualSim(it[0], it[1]) }
            }
                },
        }

    private val _isIconVisible: Flow<Boolean> =
        combine(_dualSim, mobileIconsViewModel.isStackable) { dualSim, isStackable ->
            dualSim != null && isStackable
        }

    override val dualSim: DualSim? by
        hydrator.hydratedStateOf(traceName = "dualSim", source = _dualSim, initialValue = null)

    /** Content description of both icons, starting with the active connection. */
    override val contentDescription: String? by
        hydrator.hydratedStateOf(
            traceName = "contentDescription",
            source =
                flowIfIconIsVisible(
                    iconViewModelFlow.flatMapLatest { viewModels ->
                        combine(viewModels.map { it.contentDescription }) { contentDescriptions ->
                                contentDescriptions.map { it?.loadContentDescription(context) }
                            }
                            .map { loadedStrings ->
                                // Only provide the content description if both icons have one
                                if (loadedStrings.any { it == null }) {
                                    null
                                } else {
                                    // The content description of each icon has the format:
                                    // "[Carrier name], N bars."
                                    // To combine, we simply join them with a space
                                    loadedStrings.joinToString(" ")
                                }
                            }
                    }
                ),
            initialValue = null,
        )

@@ -85,13 +112,30 @@ constructor(mobileIconsViewModel: MobileIconsViewModel) :
        hydrator.hydratedStateOf(
            traceName = "networkTypeIcon",
            source =
                flowIfIconIsVisible(
                    iconViewModelFlow.flatMapLatest { viewModels ->
                        viewModels.firstOrNull()?.networkTypeIcon ?: flowOf(null)
                },
                    }
                ),
            initialValue = null,
        )

    override val isIconVisible: Boolean by derivedStateOf { isStackable && dualSim != null }
    override val isIconVisible: Boolean by
        hydrator.hydratedStateOf(
            traceName = "isIconVisible",
            source = _isIconVisible,
            initialValue = false,
        )

    private fun <T> flowIfIconIsVisible(flow: Flow<T>): Flow<T?> {
        return _isIconVisible.flatMapLatest { isVisible ->
            if (isVisible) {
                flow
            } else {
                flowOf(null)
            }
        }
    }

    override suspend fun onActivated(): Nothing {
        hydrator.activate()
+27 −2
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel

import android.content.Context
import androidx.compose.runtime.getValue
import com.android.systemui.KairosBuilder
import com.android.systemui.common.shared.model.Icon
@@ -25,7 +26,9 @@ import com.android.systemui.kairos.combine
import com.android.systemui.kairos.flatMap
import com.android.systemui.kairos.stateOf
import com.android.systemui.kairosBuilder
import com.android.systemui.shade.ShadeDisplayAware
import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
import com.android.systemui.statusbar.pipeline.mobile.ui.model.MobileContentDescription
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.StackedMobileIconViewModel.DualSim
import com.android.systemui.util.composable.kairos.hydratedComposeStateOf
import dagger.assisted.AssistedFactory
@@ -34,8 +37,10 @@ import dagger.assisted.AssistedInject
@OptIn(ExperimentalKairosApi::class)
class StackedMobileIconViewModelKairos
@AssistedInject
constructor(mobileIcons: MobileIconsViewModelKairos) :
    KairosBuilder by kairosBuilder(), StackedMobileIconViewModel {
constructor(
    mobileIcons: MobileIconsViewModelKairos,
    @ShadeDisplayAware private val context: Context,
) : KairosBuilder by kairosBuilder(), StackedMobileIconViewModel {

    private val isStackable: Boolean by
        hydratedComposeStateOf(mobileIcons.isStackable, initialValue = false)
@@ -56,6 +61,18 @@ constructor(mobileIcons: MobileIconsViewModelKairos) :
            initialValue = null,
        )

    override val contentDescription: String? by
        hydratedComposeStateOf(
            iconList.flatMap { icons ->
                icons
                    .map { it.contentDescription }
                    .combine { contentDescriptions ->
                        tryParseContentDescriptions(contentDescriptions)
                    }
            },
            initialValue = null,
        )

    override val networkTypeIcon: Icon.Resource? by
        hydratedComposeStateOf(
            iconList.flatMap { icons -> icons.firstOrNull()?.networkTypeIcon ?: stateOf(null) },
@@ -79,6 +96,14 @@ constructor(mobileIcons: MobileIconsViewModelKairos) :
        return first?.let { second?.let { DualSim(first, second) } }
    }

    private fun tryParseContentDescriptions(
        contentDescriptions: List<MobileContentDescription?>
    ): String? {
        if (contentDescriptions.size != 2 || null in contentDescriptions) return null

        return contentDescriptions.joinToString(" ") { it?.loadContentDescription(context) ?: "" }
    }

    @AssistedFactory
    interface Factory {
        fun create(): StackedMobileIconViewModelKairos
+13 −2
Original line number Diff line number Diff line
@@ -34,6 +34,8 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.android.systemui.common.ui.compose.Icon
@@ -79,7 +81,11 @@ fun StackedMobileIcon(viewModel: StackedMobileIconViewModel, modifier: Modifier
            Icon(it, tint = contentColor, modifier = Modifier.height(height).wrapContentWidth())
        }

        StackedMobileIcon(dualSim, contentColor)
        StackedMobileIcon(
            viewModel = dualSim,
            color = contentColor,
            contentDescription = viewModel.contentDescription,
        )
    }
}

@@ -87,6 +93,7 @@ fun StackedMobileIcon(viewModel: StackedMobileIconViewModel, modifier: Modifier
private fun StackedMobileIcon(
    viewModel: StackedMobileIconViewModel.DualSim,
    color: Color,
    contentDescription: String?,
    modifier: Modifier = Modifier,
) {
    // Removing 1 to get the real number of bars
@@ -95,7 +102,11 @@ private fun StackedMobileIcon(
    val iconSize =
        with(LocalDensity.current) { dimensions.totalWidth.toDp() to IconHeightSp.toDp() }

    Canvas(modifier.width(iconSize.first).height(iconSize.second)) {
    Canvas(
        modifier.width(iconSize.first).height(iconSize.second).semantics {
            contentDescription?.let { this.contentDescription = it }
        }
    ) {
        val verticalPaddingPx = BarsVerticalPaddingSp.roundToPx()
        val horizontalPaddingPx = dimensions.barsHorizontalPadding.roundToPx()
        val totalPaddingWidthPx = horizontalPaddingPx * (numberOfBars - 1)
Loading