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

Commit 6f23fd9d authored by Ahmed Mehfooz's avatar Ahmed Mehfooz Committed by Android (Google) Code Review
Browse files

Merge changes from topic "MobileIconCompose" into main

* changes:
  [SB][ComposeIcons] Add composable mobile icons
  [SB][ComposeIcons] Replace legacy status icons in the UI
  [SB][ComposeIcons] Add support for IsAreaDark
parents 10aa6d61 92ad5b42
Loading
Loading
Loading
Loading
+114 −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.systemstatusicons.mobile.ui.viewmodel

import android.content.testableContext
import android.platform.test.annotations.EnableFlags
import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.statusbar.core.NewStatusBarIcons
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.fakeMobileIconsInteractor
import com.android.systemui.statusbar.systemstatusicons.SystemStatusIconsInCompose
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@EnableFlags(SystemStatusIconsInCompose.FLAG_NAME, NewStatusBarIcons.FLAG_NAME)
@SmallTest
@RunWith(AndroidJUnit4::class)
class MobileSystemStatusIconsViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val underTest =
        kosmos.mobileSystemStatusIconsViewModelFactory.create(kosmos.testableContext).apply {
            activateIn(kosmos.testScope)
        }

    @Test fun visible_default_isFalse() = kosmos.runTest { assertThat(underTest.visible).isFalse() }

    @Test
    fun visible_whenSubsListIsPopulated_isTrue() =
        kosmos.runTest {
            fakeMobileIconsInteractor.filteredSubscriptions.value = emptyList()
            assertThat(underTest.visible).isFalse()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1)

            assertThat(underTest.visible).isTrue()
        }

    @Test
    fun visible_whenSubsListIsCleared_isFalse() =
        kosmos.runTest {
            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1)
            assertThat(underTest.visible).isTrue()

            fakeMobileIconsInteractor.filteredSubscriptions.value = emptyList()

            assertThat(underTest.visible).isFalse()
        }

    @Test
    fun visible_subscriptionChanges_flipsCorrectly() =
        kosmos.runTest {
            assertThat(underTest.visible).isFalse()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2)
            assertThat(underTest.visible).isTrue()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2, SUB_3)
            assertThat(underTest.visible).isTrue()

            fakeMobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1)
            assertThat(underTest.visible).isTrue()

            fakeMobileIconsInteractor.filteredSubscriptions.value = emptyList()
            assertThat(underTest.visible).isFalse()
        }

    companion object {
        private val SUB_1 =
            SubscriptionModel(
                subscriptionId = 1,
                isOpportunistic = false,
                carrierName = "Carrier 1",
                profileClass = PROFILE_CLASS_UNSET,
            )
        private val SUB_2 =
            SubscriptionModel(
                subscriptionId = 2,
                isOpportunistic = false,
                carrierName = "Carrier 2",
                profileClass = PROFILE_CLASS_UNSET,
            )
        private val SUB_3 =
            SubscriptionModel(
                subscriptionId = 3,
                isOpportunistic = false,
                carrierName = "Carrier 3",
                profileClass = PROFILE_CLASS_UNSET,
            )
    }
}
+325 −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.pipeline.mobile.ui.compose

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.systemui.Flags
import com.android.systemui.common.ui.compose.load
import com.android.systemui.res.R
import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconViewModelCommon

/** Composable for displaying a single mobile icon. */
@Composable
fun MobileIcon(viewModel: MobileIconViewModelCommon, modifier: Modifier = Modifier) {
    val isVisible by viewModel.isVisible.collectAsStateWithLifecycle()

    if (!isVisible) return

    val icon by viewModel.icon.collectAsStateWithLifecycle(initialValue = SignalIconModel.DEFAULT)
    if (icon !is SignalIconModel.Cellular) return

    val contentDescription by
        viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null)
    val networkTypeIcon by
        viewModel.networkTypeIcon.collectAsStateWithLifecycle(initialValue = null)
    val roaming by viewModel.roaming.collectAsStateWithLifecycle(initialValue = false)
    val activityInVisible by
        viewModel.activityInVisible.collectAsStateWithLifecycle(initialValue = false)
    val activityOutVisible by
        viewModel.activityOutVisible.collectAsStateWithLifecycle(initialValue = false)
    val activityContainerVisible by
        viewModel.activityContainerVisible.collectAsStateWithLifecycle(initialValue = false)
    val context = LocalContext.current
    val contentColor = LocalContentColor.current
    val spacing = with(LocalDensity.current) { MobileIconDimensions.IconSpacingSp.toDp() }

    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier =
            modifier.semantics {
                contentDescription?.let {
                    this.contentDescription = it.loadContentDescription(context)
                }
            },
    ) {
        if (activityContainerVisible) {
            Column {
                ActivityIndicators(
                    activityInVisible = activityInVisible,
                    activityOutVisible = activityOutVisible,
                    color = contentColor,
                )
            }
        }

        networkTypeIcon?.let { networkIcon ->
            val height = with(LocalDensity.current) { MobileIconDimensions.IconHeightSp.toDp() }
            Box(modifier = Modifier.height(height), contentAlignment = Alignment.Center) {
                Image(
                    painter = painterResource(networkIcon.res),
                    contentDescription = networkIcon.contentDescription?.load(),
                    modifier = Modifier.height(height),
                    colorFilter = ColorFilter.tint(contentColor, BlendMode.SrcIn),
                    contentScale = ContentScale.FillHeight,
                )
            }
        }

        Spacer(Modifier.size(spacing))

        MobileSignalIcon(viewModel = icon as SignalIconModel.Cellular, color = contentColor)

        Spacer(Modifier.size(spacing))

        if (roaming) {
            val height =
                with(LocalDensity.current) { MobileIconDimensions.RoamingIconHeightSp.toDp() }
            val paddingTop =
                with(LocalDensity.current) { MobileIconDimensions.RoamingIconPaddingTopSp.toDp() }
            Image(
                painter = painterResource(R.drawable.stat_sys_roaming_updated),
                contentDescription = stringResource(R.string.data_connection_roaming),
                modifier = Modifier.height(height).offset(y = paddingTop),
                colorFilter = ColorFilter.tint(contentColor, BlendMode.SrcIn),
                contentScale = ContentScale.FillHeight,
            )
        }
    }
}

/** Composable for activity indicators (data in/out arrows) */
@Composable
private fun ActivityIndicators(
    activityInVisible: Boolean,
    activityOutVisible: Boolean,
    color: Color,
    modifier: Modifier = Modifier,
) {
    val useStaticIndicators = Flags.statusBarStaticInoutIndicators()
    val activityIndicatorSize =
        with(LocalDensity.current) { MobileIconDimensions.ActivityIndicatorSizeSp.toDp() }
    Box(modifier = modifier.height(activityIndicatorSize + 8.dp).padding(bottom = 4.dp)) {
        Image(
            painter = painterResource(id = R.drawable.ic_activity_up),
            contentDescription = null,
            colorFilter = ColorFilter.tint(color, BlendMode.SrcIn),
            contentScale = ContentScale.None,
            alignment = Alignment.TopEnd,
            alpha =
                if (useStaticIndicators) (if (activityInVisible) 1f else 0.3f)
                else if (activityInVisible) 1f else 0f,
            modifier =
                if (!useStaticIndicators && !activityInVisible) Modifier.size(0.dp) else Modifier,
        )
        Image(
            painter = painterResource(id = R.drawable.ic_activity_down),
            contentDescription = null,
            colorFilter = ColorFilter.tint(color, BlendMode.SrcIn),
            contentScale = ContentScale.None,
            alignment = Alignment.BottomEnd,
            alpha =
                if (useStaticIndicators) (if (activityOutVisible) 1f else 0.3f)
                else if (activityOutVisible) 1f else 0f,
            modifier =
                if (!useStaticIndicators && !activityOutVisible) Modifier.size(0.dp) else Modifier,
        )
    }
}

/** Composable for rendering the mobile signal strength */
@Composable
private fun MobileSignalIcon(
    viewModel: SignalIconModel.Cellular,
    color: Color,
    modifier: Modifier = Modifier,
) {
    val height = with(LocalDensity.current) { MobileIconDimensions.IconHeightSp.toDp() }

    val numberOfBars = viewModel.numberOfLevels - 1
    val dimensions =
        if (numberOfBars == 5) mobileSignalFiveBarsDimensions else mobileSignalFourBarsDimensions
    val width = with(LocalDensity.current) { dimensions.totalWidth.toDp() }

    Canvas(
        modifier.width(width).height(height).graphicsLayer {
            compositingStrategy = CompositingStrategy.Offscreen
        }
    ) {
        val rtl = layoutDirection == LayoutDirection.Rtl
        scale(if (rtl) -1f else 1f, 1f) {
            val horizontalPaddingPx = dimensions.barsHorizontalPadding.roundToPx()
            val totalPaddingWidthPx = horizontalPaddingPx * (numberOfBars - 1)
            val barWidthPx = (size.width - totalPaddingWidthPx) / numberOfBars
            val baseBarHeightPx = dimensions.barBaseHeight.toPx()
            val levelIncrementPx = dimensions.barsLevelIncrement.toPx()

            var xOffsetPx = 0f
            for (bar in 1..numberOfBars) {
                val barHeightPx = baseBarHeightPx + (levelIncrementPx * (bar - 1))
                val barYOffsetPx = size.height - barHeightPx

                drawMobileSignalBar(
                    level = viewModel.level,
                    bar = bar,
                    topLeft = Offset(xOffsetPx, barYOffsetPx),
                    size = Size(barWidthPx, barHeightPx),
                    activeColor = color,
                )

                xOffsetPx += barWidthPx + horizontalPaddingPx
            }

            // Draw exclamation mark if needed
            if (viewModel.showExclamationMark) {
                drawSignalExclamationCutout(color)
            }
        }
    }
}

private fun DrawScope.drawMobileSignalBar(
    level: Int,
    bar: Int,
    topLeft: Offset,
    size: Size,
    activeColor: Color,
    inactiveColor: Color = activeColor.copy(alpha = .3f),
    cornerRadius: CornerRadius = CornerRadius(size.width / 2),
) {
    drawRoundRect(
        color = if (level >= bar) activeColor else inactiveColor,
        topLeft = topLeft,
        size = size,
        cornerRadius = cornerRadius,
    )
}

private fun DrawScope.drawSignalExclamationCutout(color: Color) {
    // Exclamation mark dimensions
    val exclamationDiameterPx = MobileSignalDimensions.ExclamationDiameterSp.toPx()
    val exclamationRadiusPx = exclamationDiameterPx / 2
    val exclamationHeightPx = MobileSignalDimensions.ExclamationHeightSp.toPx()
    val exclamationVerticalSpacingPx = MobileSignalDimensions.ExclamationVerticalSpacing.toPx()
    val exclamationTotalHeight =
        exclamationHeightPx + exclamationVerticalSpacingPx + exclamationDiameterPx
    val exclamationHorizontalOffsetPx = MobileSignalDimensions.ExclamationHorizontalOffset.toPx()

    // Position exclamation mark bottom-aligned with canvas
    val exclamationDotCenter =
        Offset(size.width - exclamationHorizontalOffsetPx, size.height - exclamationRadiusPx)
    val exclamationMarkTopLeft =
        Offset(exclamationDotCenter.x - exclamationRadiusPx, size.height - exclamationTotalHeight)
    val exclamationCornerRadius = CornerRadius(exclamationRadiusPx)
    val cutoutCenter = Offset(exclamationDotCenter.x, size.height - (exclamationTotalHeight / 2))

    // Transparent cutout
    drawCircle(
        color = Color.Transparent,
        radius = MobileSignalDimensions.ExclamationCutoutRadiusSp.toPx(),
        center = cutoutCenter,
        blendMode = BlendMode.SrcIn,
    )

    // Top bar for the exclamation mark
    drawRoundRect(
        color = color,
        topLeft = exclamationMarkTopLeft,
        size = Size(exclamationDiameterPx, exclamationHeightPx),
        cornerRadius = exclamationCornerRadius,
    )

    // Bottom circle for the exclamation mark
    drawCircle(color = color, center = exclamationDotCenter, radius = exclamationRadiusPx)
}

// Dimension class for mobile signal icon
private data class MobileSignalBarsDimensions(
    val totalWidth: TextUnit,
    val barsHorizontalPadding: TextUnit,
    val barBaseHeight: TextUnit,
    val barsLevelIncrement: TextUnit,
)

private val mobileSignalFourBarsDimensions =
    MobileSignalBarsDimensions(
        totalWidth = 17.sp,
        barsHorizontalPadding = 2.sp,
        barBaseHeight = 6.sp,
        barsLevelIncrement = 2.sp,
    )

private val mobileSignalFiveBarsDimensions =
    MobileSignalBarsDimensions(
        totalWidth = 18.5.sp,
        barsHorizontalPadding = 1.5.sp,
        barBaseHeight = 4.5.sp,
        barsLevelIncrement = 1.75.sp,
    )

private object MobileSignalDimensions {
    val ExclamationCutoutRadiusSp = 5.sp
    val ExclamationDiameterSp = 1.5.sp
    val ExclamationHeightSp = 4.5.sp
    val ExclamationVerticalSpacing = 1.sp
    val ExclamationHorizontalOffset = 1.sp
}

private object MobileIconDimensions {
    val IconHeightSp = 12.sp
    val IconSpacingSp = 2.sp
    val RoamingIconHeightSp = 10.sp
    val RoamingIconPaddingTopSp = 1.sp
    val ActivityIndicatorSizeSp = 12.sp
}
+60 −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.pipeline.mobile.ui.compose

import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
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.LocalDensity
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.StackedMobileIconViewModel
import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIcon

/** Renders the mobile icons for the status bar. */
@Composable
fun MobileIcons(
    viewModel: MobileIconsViewModel,
    stackedMobileIconViewModel: StackedMobileIconViewModel,
    modifier: Modifier = Modifier,
) {
    val isStackable by viewModel.isStackable.collectAsStateWithLifecycle()
    if (isStackable) {
        StackedMobileIcon(viewModel = stackedMobileIconViewModel, modifier = modifier)
    } else {
        val mobileSubViewModels by viewModel.mobileSubViewModels.collectAsStateWithLifecycle()
        val iconPaddingSp = 4.sp
        val iconSpacingSp = 2.sp
        val padding = with(LocalDensity.current) { iconPaddingSp.toDp() }
        val spacing = with(LocalDensity.current) { iconSpacingSp.toDp() }

        Row(
            horizontalArrangement = spacedBy(spacing),
            verticalAlignment = Alignment.CenterVertically,
            modifier = modifier.padding(horizontal = padding),
        ) {
            mobileSubViewModels.forEach { mobileViewModel ->
                MobileIcon(viewModel = mobileViewModel)
            }
        }
    }
}
+27 −25
Original line number Diff line number Diff line
@@ -186,18 +186,6 @@ fun StatusBarRoot(
            val phoneStatusBarView =
                inflater.inflate(R.layout.status_bar, parent, false) as PhoneStatusBarView

            // For now, just set up the system icons the same way we used to
            val statusIconContainer =
                phoneStatusBarView.requireViewById<StatusIconContainer>(R.id.statusIcons)
            // TODO(b/364360986): turn this into a repo/intr/viewmodel
            val darkIconManager =
                darkIconManagerFactory.create(
                    statusIconContainer,
                    StatusBarLocation.HOME,
                    darkIconDispatcher,
                )
            iconController.addIconGroup(darkIconManager)

            if (StatusBarChipsModernization.isEnabled) {
                addStartSideChipsComposable(
                    phoneStatusBarView = phoneStatusBarView,
@@ -208,12 +196,6 @@ fun StatusBarRoot(
                )
            }

            HomeStatusBarIconBlockListBinder.bind(
                statusIconContainer,
                darkIconManager,
                statusBarViewModel.iconBlockList,
            )

            if (StatusBarChipsModernization.isEnabled) {
                // Make sure the primary chip is hidden when StatusBarChipsModernization is
                // enabled. OngoingActivityChips will be shown in a composable container
@@ -281,14 +263,33 @@ fun StatusBarRoot(
            // If the flag is enabled, create and add a compose section to the end
            // of the system_icons container
            if (SystemStatusIconsInCompose.isEnabled) {
                phoneStatusBarView.requireViewById<View>(R.id.system_icons).visibility = View.GONE
                addSystemStatusIconsComposable(phoneStatusBarView, statusBarViewModel)
            } else if (NewStatusBarIcons.isEnabled) {
            } else {
                val statusIconContainer =
                    phoneStatusBarView.requireViewById<StatusIconContainer>(R.id.statusIcons)
                val darkIconManager =
                    darkIconManagerFactory.create(
                        statusIconContainer,
                        StatusBarLocation.HOME,
                        darkIconDispatcher,
                    )
                iconController.addIconGroup(darkIconManager)

                HomeStatusBarIconBlockListBinder.bind(
                    statusIconContainer,
                    darkIconManager,
                    statusBarViewModel.iconBlockList,
                )

                if (NewStatusBarIcons.isEnabled) {
                    addBatteryComposable(phoneStatusBarView, statusBarViewModel)
                    // Also adjust the paddings :)
                    SystemStatusIconsLayoutHelper.configurePaddingForNewStatusBarIcons(
                        phoneStatusBarView.requireViewById(R.id.statusIcons)
                    )
                }
            }

            notificationIconsBinder.bindWhileAttached(notificationIconContainer, context.displayId)

@@ -510,8 +511,9 @@ private fun addSystemStatusIconsComposable(
                }
            }
        }
    phoneStatusBarView.findViewById<ViewGroup>(R.id.status_bar_end_side_container).apply {
        addView(systemStatusIconsComposeView, -1)

    phoneStatusBarView.findViewById<ViewGroup>(R.id.status_bar_end_side_content).apply {
        addView(systemStatusIconsComposeView)
    }
}

+67 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading