summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Olivier St-Onge <ostonge@google.com> 2025-02-12 12:52:56 -0800
committer Android (Google) Code Review <android-gerrit@google.com> 2025-02-12 12:52:56 -0800
commit982cee253d6ad96d502ca87c0133c5ba8a2c95e5 (patch)
tree3e74c9194cc2c2a8bae5115d2d95abd0f76a8e2d
parentbec48ec2971cbd67124a2ec0ee120f5e1f70b384 (diff)
parent27980c603890502fe9eb70557f77128323670ef1 (diff)
Merge "Initial implementation for dual sim icon" into main
-rw-r--r--core/res/res/values/config.xml2
-rw-r--r--core/res/res/values/symbols.xml1
-rw-r--r--packages/SystemUI/res/layout/bindable_status_bar_compose_icon.xml33
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/icons/shared/BindableIconsRegistry.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt19
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt26
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt16
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/StackedMobileBindableIcon.kt52
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/StackedMobileIconBinder.kt65
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt11
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/StackedMobileIconViewModel.kt90
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/composable/StackedMobileIcon.kt180
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/view/SingleBindableStatusBarComposeIconView.kt155
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt3
14 files changed, 641 insertions, 18 deletions
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index 17acf9aed278..1a311d572e0b 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -60,6 +60,7 @@
<item><xliff:g id="id">@string/status_bar_oem_satellite</xliff:g></item>
<item><xliff:g id="id">@string/status_bar_wifi</xliff:g></item>
<item><xliff:g id="id">@string/status_bar_hotspot</xliff:g></item>
+ <item><xliff:g id="id">@string/status_bar_stacked_mobile</xliff:g></item>
<item><xliff:g id="id">@string/status_bar_mobile</xliff:g></item>
<item><xliff:g id="id">@string/status_bar_airplane</xliff:g></item>
<item><xliff:g id="id">@string/status_bar_battery</xliff:g></item>
@@ -94,6 +95,7 @@
<string translatable="false" name="status_bar_secure">secure</string>
<string translatable="false" name="status_bar_clock">clock</string>
<string translatable="false" name="status_bar_mobile">mobile</string>
+ <string translatable="false" name="status_bar_stacked_mobile">stacked_mobile</string>
<string translatable="false" name="status_bar_vpn">vpn</string>
<string translatable="false" name="status_bar_ethernet">ethernet</string>
<string translatable="false" name="status_bar_microphone">microphone</string>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 63db28683b2b..c62732d36038 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -3320,6 +3320,7 @@
<java-symbol type="string" name="status_bar_no_calling" />
<java-symbol type="string" name="status_bar_call_strength" />
<java-symbol type="string" name="status_bar_mobile" />
+ <java-symbol type="string" name="status_bar_stacked_mobile" />
<java-symbol type="string" name="status_bar_ethernet" />
<java-symbol type="string" name="status_bar_vpn" />
<java-symbol type="string" name="status_bar_microphone" />
diff --git a/packages/SystemUI/res/layout/bindable_status_bar_compose_icon.xml b/packages/SystemUI/res/layout/bindable_status_bar_compose_icon.xml
new file mode 100644
index 000000000000..fa9318bc151c
--- /dev/null
+++ b/packages/SystemUI/res/layout/bindable_status_bar_compose_icon.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright (C) 2023 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.
+ -->
+
+<!-- Base layout that provides a single bindable compose view -->
+<com.android.systemui.statusbar.pipeline.shared.ui.view.SingleBindableStatusBarComposeIconView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:gravity="center_vertical"
+ >
+
+ <androidx.compose.ui.platform.ComposeView
+ android:id="@+id/compose_view"
+ android:layout_height="@dimen/status_bar_bindable_icon_size"
+ android:layout_width="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:padding="4sp"
+ />
+
+</com.android.systemui.statusbar.pipeline.shared.ui.view.SingleBindableStatusBarComposeIconView>
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/icons/shared/BindableIconsRegistry.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/icons/shared/BindableIconsRegistry.kt
index 8400fb08e147..701dae1594f5 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/icons/shared/BindableIconsRegistry.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/icons/shared/BindableIconsRegistry.kt
@@ -18,6 +18,7 @@ package com.android.systemui.statusbar.pipeline.icons.shared
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.pipeline.icons.shared.model.BindableIcon
+import com.android.systemui.statusbar.pipeline.mobile.ui.StackedMobileBindableIcon
import com.android.systemui.statusbar.pipeline.satellite.ui.DeviceBasedSatelliteBindableIcon
import javax.inject.Inject
@@ -40,11 +41,12 @@ class BindableIconsRegistryImpl
@Inject
constructor(
/** Bindables go here */
- oemSatellite: DeviceBasedSatelliteBindableIcon
+ oemSatellite: DeviceBasedSatelliteBindableIcon,
+ stackedMobile: StackedMobileBindableIcon,
) : BindableIconsRegistry {
/**
* Adding the injected bindables to this list will get them registered with
* StatusBarIconController
*/
- override val bindableIcons: List<BindableIcon> = listOf(oemSatellite)
+ override val bindableIcons: List<BindableIcon> = listOf(oemSatellite, stackedMobile)
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
index ac3728d9dcaf..c52536d2b312 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
@@ -21,6 +21,7 @@ import android.telephony.CarrierConfigManager
import android.telephony.SubscriptionManager
import android.telephony.SubscriptionManager.PROFILE_CLASS_PROVISIONING
import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.flags.Flags
import com.android.settingslib.mobile.TelephonyIcons
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
@@ -28,6 +29,7 @@ import com.android.systemui.flags.FeatureFlagsClassic
import com.android.systemui.flags.Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS
import com.android.systemui.log.table.TableLogBuffer
import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.core.StatusBarRootModernization
import com.android.systemui.statusbar.pipeline.dagger.MobileSummaryLog
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
@@ -39,6 +41,7 @@ import com.android.systemui.util.CarrierConfigTracker
import java.lang.ref.WeakReference
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -79,6 +82,9 @@ interface MobileIconsInteractor {
*/
val icons: StateFlow<List<MobileIconInteractor>>
+ /** Whether the mobile icons can be stacked vertically. */
+ val isStackable: StateFlow<Boolean>
+
/** True if the active mobile data subscription has data enabled */
val activeDataConnectionHasDataEnabled: StateFlow<Boolean>
@@ -126,6 +132,7 @@ interface MobileIconsInteractor {
fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor
}
+@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("EXPERIMENTAL_IS_NOT_ENABLED")
@SysUISingleton
class MobileIconsInteractorImpl
@@ -290,6 +297,18 @@ constructor(
}
.stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
+ override val isStackable =
+ if (Flags.newStatusBarIcons() && StatusBarRootModernization.isEnabled) {
+ icons.flatMapLatest { icons ->
+ combine(icons.map { it.isNonTerrestrial }) {
+ it.size == 2 && it.none { isNonTerrestrial -> isNonTerrestrial }
+ }
+ }
+ } else {
+ flowOf(false)
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
/**
* Copied from the old pipeline. We maintain a 2s period of time where we will keep the
* validated bit from the old active network (A) while data is changing to the new one (B).
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt
index 30cc2c5da994..abd543d78687 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt
@@ -16,6 +16,7 @@
package com.android.systemui.statusbar.pipeline.mobile.ui
+import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.CoreStartable
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
@@ -27,7 +28,7 @@ import java.io.PrintWriter
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
-import com.android.app.tracing.coroutines.launchTraced as launch
+import kotlinx.coroutines.flow.combine
/**
* This class is intended to provide a context to collect on the
@@ -56,12 +57,23 @@ constructor(
// Start notifying the icon controller of subscriptions
scope.launch {
isCollecting = true
- mobileIconsViewModel.subscriptionIdsFlow.collectLatest {
- logger.logUiAdapterSubIdsSentToIconController(it)
- lastValue = it
- iconController.setNewMobileIconSubIds(it)
- shadeCarrierGroupController?.updateModernMobileIcons(it)
- }
+ combine(
+ mobileIconsViewModel.subscriptionIdsFlow,
+ mobileIconsViewModel.isStackable,
+ ::Pair,
+ )
+ .collectLatest { (subIds, isStackable) ->
+ logger.logUiAdapterSubIdsSentToIconController(subIds, isStackable)
+ lastValue = subIds
+ if (isStackable) {
+ // Passing an empty list to remove pre-existing mobile icons.
+ // StackedMobileBindableIcon will show the stacked icon instead.
+ iconController.setNewMobileIconSubIds(emptyList())
+ } else {
+ iconController.setNewMobileIconSubIds(subIds)
+ }
+ shadeCarrierGroupController?.updateModernMobileIcons(subIds)
+ }
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt
index 2af6795b39c4..4c2849de34ee 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt
@@ -31,22 +31,24 @@ import javax.inject.Inject
@SysUISingleton
class MobileViewLogger
@Inject
-constructor(
- @MobileViewLog private val buffer: LogBuffer,
- dumpManager: DumpManager,
-) : Dumpable {
+constructor(@MobileViewLog private val buffer: LogBuffer, dumpManager: DumpManager) : Dumpable {
init {
dumpManager.registerNormalDumpable(this)
}
private val collectionStatuses = mutableMapOf<String, Boolean>()
- fun logUiAdapterSubIdsSentToIconController(subs: List<Int>) {
+ fun logUiAdapterSubIdsSentToIconController(subs: List<Int>, isStackable: Boolean) {
buffer.log(
TAG,
LogLevel.INFO,
- { str1 = subs.toString() },
- { "Sub IDs in MobileUiAdapter being sent to icon controller: $str1" },
+ {
+ str1 = subs.toString()
+ bool1 = isStackable
+ },
+ {
+ "Sub IDs in MobileUiAdapter being sent to icon controller: $str1, isStackable=$bool1"
+ },
)
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/StackedMobileBindableIcon.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/StackedMobileBindableIcon.kt
new file mode 100644
index 000000000000..fa9fa4c1366f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/StackedMobileBindableIcon.kt
@@ -0,0 +1,52 @@
+/*
+ * 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
+
+import android.content.Context
+import com.android.settingslib.flags.Flags
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.statusbar.core.StatusBarRootModernization
+import com.android.systemui.statusbar.pipeline.icons.shared.model.BindableIcon
+import com.android.systemui.statusbar.pipeline.icons.shared.model.ModernStatusBarViewCreator
+import com.android.systemui.statusbar.pipeline.mobile.ui.binder.StackedMobileIconBinder
+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.view.SingleBindableStatusBarComposeIconView
+import javax.inject.Inject
+
+@SysUISingleton
+class StackedMobileBindableIcon
+@Inject
+constructor(
+ context: Context,
+ mobileIconsViewModel: MobileIconsViewModel,
+ viewModelFactory: StackedMobileIconViewModel.Factory,
+) : BindableIcon {
+ override val slot: String =
+ context.getString(com.android.internal.R.string.status_bar_stacked_mobile)
+
+ override val initializer = ModernStatusBarViewCreator { context ->
+ SingleBindableStatusBarComposeIconView.createView(context).also { view ->
+ view.initView(slot) {
+ StackedMobileIconBinder.bind(view, mobileIconsViewModel, viewModelFactory)
+ }
+ }
+ }
+
+ override val shouldBindIcon: Boolean =
+ Flags.newStatusBarIcons() && StatusBarRootModernization.isEnabled
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/StackedMobileIconBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/StackedMobileIconBinder.kt
new file mode 100644
index 000000000000..c9fc53ecadc0
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/StackedMobileIconBinder.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.binder
+
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.lifecycle.rememberViewModel
+import com.android.systemui.lifecycle.repeatWhenAttached
+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.binder.ModernStatusBarViewBinding
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIcon
+import com.android.systemui.statusbar.pipeline.shared.ui.view.SingleBindableStatusBarComposeIconView
+
+object StackedMobileIconBinder {
+ fun bind(
+ view: SingleBindableStatusBarComposeIconView,
+ mobileIconsViewModel: MobileIconsViewModel,
+ viewModelFactory: StackedMobileIconViewModel.Factory,
+ ): ModernStatusBarViewBinding {
+ return SingleBindableStatusBarComposeIconView.withDefaultBinding(
+ view = view,
+ shouldBeVisible = { mobileIconsViewModel.isStackable.value },
+ ) { _, tint ->
+ view.repeatWhenAttached {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ view.composeView.apply {
+ setViewCompositionStrategy(
+ ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
+ )
+ setContent {
+ val viewModel =
+ rememberViewModel("StackedMobileIconBinder") {
+ viewModelFactory.create()
+ }
+ if (viewModel.isIconVisible) {
+ CompositionLocalProvider(LocalContentColor provides Color(tint())) {
+ StackedMobileIcon(viewModel)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt
index 22feb7ce77c8..6176a3e9e281 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt
@@ -70,15 +70,20 @@ constructor(
}
.stateIn(scope, SharingStarted.WhileSubscribed(), listOf())
- private val firstMobileSubViewModel: StateFlow<MobileIconViewModelCommon?> =
+ val mobileSubViewModels: StateFlow<List<MobileIconViewModelCommon>> =
subscriptionIdsFlow
+ .map { ids -> ids.map { commonViewModelForSub(it) } }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
+
+ private val firstMobileSubViewModel: StateFlow<MobileIconViewModelCommon?> =
+ mobileSubViewModels
.map {
if (it.isEmpty()) {
null
} else {
// Mobile icons get reversed by [StatusBarIconController], so the last element
// in this list will show up visually first.
- commonViewModelForSub(it.last())
+ it.last()
}
}
.stateIn(scope, SharingStarted.WhileSubscribed(), null)
@@ -94,6 +99,8 @@ constructor(
}
.stateIn(scope, SharingStarted.WhileSubscribed(), false)
+ val isStackable: StateFlow<Boolean> = interactor.isStackable
+
init {
scope.launch { subscriptionIdsFlow.collect { invalidateCaches(it) } }
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/StackedMobileIconViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/StackedMobileIconViewModel.kt
new file mode 100644
index 000000000000..a2c2a3cd1507
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/StackedMobileIconViewModel.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.viewmodel
+
+import androidx.compose.runtime.derivedStateOf
+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.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class StackedMobileIconViewModel
+@AssistedInject
+constructor(mobileIconsViewModel: MobileIconsViewModel) : ExclusiveActivatable() {
+ private val hydrator = Hydrator("StackedMobileIconViewModel")
+
+ private val isStackable: Boolean by
+ hydrator.hydratedStateOf(
+ traceName = "isStackable",
+ source = mobileIconsViewModel.isStackable,
+ initialValue = false,
+ )
+
+ private val iconViewModelFlow: StateFlow<List<MobileIconViewModelCommon>> =
+ mobileIconsViewModel.mobileSubViewModels
+
+ val dualSim: DualSim? by
+ hydrator.hydratedStateOf(
+ traceName = "dualSim",
+ source =
+ iconViewModelFlow.flatMapLatest { viewModels ->
+ combine(viewModels.map { it.icon }) { icons ->
+ icons
+ .toList()
+ .filterIsInstance<SignalIconModel.Cellular>()
+ .takeIf { it.size == 2 }
+ ?.let { DualSim(it[0], it[1]) }
+ }
+ },
+ initialValue = null,
+ )
+
+ val networkTypeIcon: Icon.Resource? by
+ hydrator.hydratedStateOf(
+ traceName = "networkTypeIcon",
+ source =
+ iconViewModelFlow.flatMapLatest { viewModels ->
+ viewModels.firstOrNull()?.networkTypeIcon ?: flowOf(null)
+ },
+ initialValue = null,
+ )
+
+ val isIconVisible: Boolean by derivedStateOf { isStackable && dualSim != null }
+
+ override suspend fun onActivated(): Nothing {
+ hydrator.activate()
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(): StackedMobileIconViewModel
+ }
+
+ data class DualSim(
+ val primary: SignalIconModel.Cellular,
+ val secondary: SignalIconModel.Cellular,
+ )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/composable/StackedMobileIcon.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/composable/StackedMobileIcon.kt
new file mode 100644
index 000000000000..465a43fbfb9e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/composable/StackedMobileIcon.kt
@@ -0,0 +1,180 @@
+/*
+ * 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.shared.ui.composable
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+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.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.android.compose.modifiers.height
+import com.android.compose.modifiers.width
+import com.android.systemui.common.ui.compose.Icon
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.StackedMobileIconViewModel
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.BarBaseHeightFiveBarsSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.BarBaseHeightFourBarsSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.BarsLevelIncrementSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.BarsVerticalPaddingSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.HorizontalPaddingFiveBarsSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.HorizontalPaddingFourBarsSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.IconHeightSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.IconWidthFiveBarsSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.IconWidthFourBarsSp
+import com.android.systemui.statusbar.pipeline.shared.ui.composable.StackedMobileIconDimensions.SecondaryBarHeightSp
+import kotlin.math.max
+
+/**
+ * The dual sim icon that shows both connections stacked vertically with the active connection on
+ * top
+ */
+@Composable
+fun StackedMobileIcon(viewModel: StackedMobileIconViewModel, modifier: Modifier = Modifier) {
+ val dualSim = viewModel.dualSim ?: return
+
+ val contentColor = LocalContentColor.current
+
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+ viewModel.networkTypeIcon?.let {
+ Icon(
+ it,
+ tint = contentColor,
+ modifier =
+ Modifier.height { IconHeightSp.roundToPx() }.padding(start = 1.dp, end = 2.dp),
+ )
+ }
+
+ StackedMobileIcon(dualSim, contentColor)
+ }
+}
+
+@Composable
+private fun StackedMobileIcon(
+ viewModel: StackedMobileIconViewModel.DualSim,
+ color: Color,
+ modifier: Modifier = Modifier,
+) {
+ val maxNumberOfLevels =
+ max(viewModel.primary.numberOfLevels, viewModel.secondary.numberOfLevels)
+ val dimensions = if (maxNumberOfLevels == 6) FiveBarsDimensions else FourBarsDimensions
+ val iconSize =
+ with(LocalDensity.current) { dimensions.totalWidth.toDp() to IconHeightSp.toDp() }
+
+ Canvas(modifier.size(width = iconSize.first, height = iconSize.second)) {
+ val verticalPaddingPx = BarsVerticalPaddingSp.roundToPx()
+ val horizontalPaddingPx = dimensions.barsHorizontalPadding.roundToPx()
+ val totalPaddingWidthPx = horizontalPaddingPx * (maxNumberOfLevels - 1)
+
+ val barWidthPx = (size.width - totalPaddingWidthPx) / maxNumberOfLevels
+ val dotHeightPx = SecondaryBarHeightSp.toPx()
+ val baseBarHeightPx = dimensions.barBaseHeight.toPx()
+
+ var xOffsetPx = 0f
+ for (bar in 1..maxNumberOfLevels) {
+ // Bottom dots representing secondary sim
+ val dotYOffsetPx = size.height - dotHeightPx
+ if (bar <= viewModel.secondary.numberOfLevels) {
+ drawMobileIconBar(
+ level = viewModel.secondary.level,
+ bar = bar,
+ topLeft = Offset(xOffsetPx, dotYOffsetPx),
+ size = Size(barWidthPx, dotHeightPx),
+ activeColor = color,
+ )
+ }
+
+ // Top bars representing primary sim
+ if (bar <= viewModel.primary.numberOfLevels) {
+ val barHeightPx = baseBarHeightPx + (BarsLevelIncrementSp.toPx() * (bar - 1))
+ val barYOffsetPx = dotYOffsetPx - verticalPaddingPx - barHeightPx
+ drawMobileIconBar(
+ level = viewModel.primary.level,
+ bar = bar,
+ topLeft = Offset(xOffsetPx, barYOffsetPx),
+ size = Size(barWidthPx, barHeightPx),
+ activeColor = color,
+ )
+ }
+
+ xOffsetPx += barWidthPx + horizontalPaddingPx
+ }
+ }
+}
+
+private fun DrawScope.drawMobileIconBar(
+ 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 abstract class BarsDependentDimensions(
+ val totalWidth: TextUnit,
+ val barsHorizontalPadding: TextUnit,
+ val barBaseHeight: TextUnit,
+)
+
+private object FourBarsDimensions :
+ BarsDependentDimensions(
+ IconWidthFourBarsSp,
+ HorizontalPaddingFourBarsSp,
+ BarBaseHeightFourBarsSp,
+ )
+
+private object FiveBarsDimensions :
+ BarsDependentDimensions(
+ IconWidthFiveBarsSp,
+ HorizontalPaddingFiveBarsSp,
+ BarBaseHeightFiveBarsSp,
+ )
+
+private object StackedMobileIconDimensions {
+ // Common dimensions
+ val IconHeightSp = 12.sp
+ val BarsVerticalPaddingSp = 1.5.sp
+ val BarsLevelIncrementSp = 1.sp
+ val SecondaryBarHeightSp = 3.sp
+
+ // Dimensions dependant on the number of total bars
+ val IconWidthFiveBarsSp = 18.5.sp
+ val IconWidthFourBarsSp = 16.sp
+ val HorizontalPaddingFiveBarsSp = 1.5.sp
+ val HorizontalPaddingFourBarsSp = 2.sp
+ val BarBaseHeightFiveBarsSp = 3.5.sp
+ val BarBaseHeightFourBarsSp = 4.5.sp
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/view/SingleBindableStatusBarComposeIconView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/view/SingleBindableStatusBarComposeIconView.kt
new file mode 100644
index 000000000000..8076040564fb
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/shared/ui/view/SingleBindableStatusBarComposeIconView.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2023 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.shared.ui.view
+
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.app.tracing.coroutines.launchTraced as launch
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.StatusBarIconView
+import com.android.systemui.statusbar.StatusBarIconView.STATE_HIDDEN
+import com.android.systemui.statusbar.pipeline.shared.ui.binder.ModernStatusBarViewBinding
+import com.android.systemui.statusbar.pipeline.shared.ui.binder.ModernStatusBarViewVisibilityHelper
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/** Compose view that is bound to bindable_status_bar_compose_icon.xml */
+class SingleBindableStatusBarComposeIconView(context: Context, attrs: AttributeSet?) :
+ ModernStatusBarView(context, attrs) {
+
+ internal lateinit var composeView: ComposeView
+ internal lateinit var dotView: StatusBarIconView
+
+ override fun toString(): String {
+ return "SingleBindableStatusBarComposeIcon(" +
+ "slot='$slot', " +
+ "isCollecting=${binding.isCollecting()}, " +
+ "visibleState=${StatusBarIconView.getVisibleStateString(visibleState)}); " +
+ "viewString=${super.toString()}"
+ }
+
+ override fun initView(slot: String, bindingCreator: () -> ModernStatusBarViewBinding) {
+ super.initView(slot, bindingCreator)
+
+ composeView = requireViewById(R.id.compose_view)
+ dotView = requireViewById(R.id.status_bar_dot)
+ }
+
+ companion object {
+ fun createView(context: Context): SingleBindableStatusBarComposeIconView {
+ return LayoutInflater.from(context)
+ .inflate(R.layout.bindable_status_bar_compose_icon, null)
+ as SingleBindableStatusBarComposeIconView
+ }
+
+ /**
+ * Using a given binding [block], create the necessary scaffolding to handle the general
+ * case of a single status bar icon. This includes eliding into a dot view when there is not
+ * enough space, and handling tint.
+ *
+ * [block] should be a simple [launch] call that handles updating the single icon view with
+ * its new view. Currently there is no simple way to e.g., extend to handle multiple tints
+ * for dual-layered icons, and any more complex logic should probably find a way to return
+ * its own version of [ModernStatusBarViewBinding].
+ */
+ fun withDefaultBinding(
+ view: SingleBindableStatusBarComposeIconView,
+ shouldBeVisible: () -> Boolean,
+ block: suspend LifecycleOwner.(View, () -> Int) -> Unit,
+ ): ModernStatusBarViewBinding {
+ @StatusBarIconView.VisibleState
+ val visibilityState: MutableStateFlow<Int> = MutableStateFlow(STATE_HIDDEN)
+
+ val iconTint: MutableStateFlow<Int> = MutableStateFlow(Color.WHITE)
+ val decorTint: MutableStateFlow<Int> = MutableStateFlow(Color.WHITE)
+
+ var isCollecting: Boolean = false
+
+ view.repeatWhenAttached {
+ // Child binding
+ block(view) { iconTint.value }
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ // isVisible controls the visibility state of the outer group, and thus it
+ // needs to run in the CREATED lifecycle so it can continue to watch while
+ // invisible. See (b/291031862) for details
+ launch {
+ visibilityState.collect { visibilityState ->
+ // for b/296864006, we can not hide all the child views if
+ // visibilityState is STATE_HIDDEN. Because hiding all child views
+ // would cause the getWidth() of this view return 0, and that would
+ // cause the translation calculation fails in StatusIconContainer.
+ // Therefore, like class MobileIconBinder, instead of set the child
+ // views visibility to View.GONE, we set their visibility to
+ // View.INVISIBLE to make them invisible but keep the width.
+ ModernStatusBarViewVisibilityHelper.setVisibilityState(
+ visibilityState,
+ view.composeView,
+ view.dotView,
+ )
+ }
+ }
+
+ launch { iconTint.collect { tint -> view.dotView.setDecorColor(tint) } }
+
+ launch {
+ decorTint.collect { decorTint -> view.dotView.setDecorColor(decorTint) }
+ }
+
+ try {
+ awaitCancellation()
+ } finally {
+ isCollecting = false
+ }
+ }
+ }
+ }
+
+ return object : ModernStatusBarViewBinding {
+ override fun getShouldIconBeVisible(): Boolean {
+ return shouldBeVisible()
+ }
+
+ override fun onVisibilityStateChanged(state: Int) {
+ visibilityState.value = state
+ }
+
+ override fun onIconTintChanged(newTint: Int, contrastTint: Int) {
+ iconTint.value = newTint
+ }
+
+ override fun onDecorTintChanged(newTint: Int) {
+ decorTint.value = newTint
+ }
+
+ override fun isCollecting(): Boolean {
+ return isCollecting
+ }
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt
index 352f6cf011e1..9b6f205fba72 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt
@@ -26,6 +26,7 @@ import com.android.systemui.log.table.TableLogBuffer
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
class FakeMobileIconsInteractor(
mobileMappings: MobileMappingsProxy,
@@ -73,6 +74,8 @@ class FakeMobileIconsInteractor(
override val icons: MutableStateFlow<List<MobileIconInteractor>> = MutableStateFlow(emptyList())
+ override val isStackable: StateFlow<Boolean> = MutableStateFlow(false)
+
private val _defaultMobileIconMapping = MutableStateFlow(TEST_MAPPING)
override val defaultMobileIconMapping = _defaultMobileIconMapping