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

Commit 9256daf2 authored by Olivier St-Onge's avatar Olivier St-Onge
Browse files

Add content description to the stacked mobile icon

This simply combines both content descriptions. Each already contains the carrier name and the number of bars. The active connection is listed first

Test: manually with demo mode and talkback
Test: StackedMobileIconViewModelTest
Fixes: 412700798
Flag: com.android.settingslib.flags.new_status_bar_icons
Flag: com.android.systemui.status_bar_root_modernization

Change-Id: I74edd9fcf437686e09748306a2b075056d3b9d65
parent 7870811c
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