summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/Android.bp1
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt173
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt285
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt465
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt244
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt544
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt1228
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt1350
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt868
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt1046
-rw-r--r--packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt96
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt53
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt178
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt122
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt165
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt171
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt270
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt255
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt157
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt11
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt208
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt254
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt37
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt97
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt485
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt584
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt380
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt452
-rw-r--r--packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt22
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt32
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt8
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt18
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt22
-rw-r--r--packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt22
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt106
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt69
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt41
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt87
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt134
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt149
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt (renamed from packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt)7
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt22
45 files changed, 10849 insertions, 79 deletions
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index b53cb27dd73b..5b48566d92f9 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -727,6 +727,7 @@ android_library {
"TraceurCommon",
"Traceur-res",
"aconfig_settings_flags_lib",
+ "kairos",
],
}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt
new file mode 100644
index 000000000000..80f4b2ce7b10
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairosTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+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.demoModeController
+import com.android.systemui.demomode.DemoMode
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.validMobileEvent
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+
+/**
+ * The switcher acts as a dispatcher to either the `prod` or `demo` versions of the repository
+ * interface it's switching on. These tests just need to verify that the entire interface properly
+ * switches over when the value of `demoMode` changes
+ */
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileRepositorySwitcherKairosTest : SysuiTestCase() {
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ demoModeController.stub {
+ // Never start in demo mode
+ on { isInDemoMode } doReturn false
+ }
+ wifiDataSource.stub { on { wifiEvents } doReturn MutableStateFlow(null) }
+ }
+
+ private val Kosmos.underTest
+ get() = mobileRepositorySwitcherKairos
+
+ private val Kosmos.realRepo
+ get() = mobileConnectionsRepositoryKairosImpl
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun activeRepoMatchesDemoModeSetting() = runTest {
+ demoModeController.stub { on { isInDemoMode } doReturn false }
+
+ val latest by underTest.activeRepo.collectLastValue()
+
+ assertThat(latest).isEqualTo(realRepo)
+
+ startDemoMode()
+
+ assertThat(latest).isInstanceOf(DemoMobileConnectionsRepositoryKairos::class.java)
+
+ finishDemoMode()
+
+ assertThat(latest).isEqualTo(realRepo)
+ }
+
+ @Test
+ fun subscriptionListUpdatesWhenDemoModeChanges() = runTest {
+ demoModeController.stub { on { isInDemoMode } doReturn false }
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+
+ val latest by underTest.subscriptions.collectLastValue()
+
+ // The real subscriptions has 2 subs
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2))
+
+ // Demo mode turns on, and we should see only the demo subscriptions
+ startDemoMode()
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 3))
+
+ // Demo mobile connections repository makes arbitrarily-formed subscription info
+ // objects, so just validate the data we care about
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(3)
+
+ finishDemoMode()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2))
+ }
+
+ private fun KairosTestScope.startDemoMode() {
+ demoModeController.stub { on { isInDemoMode } doReturn true }
+ getDemoModeCallback().onDemoModeStarted()
+ }
+
+ private fun KairosTestScope.finishDemoMode() {
+ demoModeController.stub { on { isInDemoMode } doReturn false }
+ getDemoModeCallback().onDemoModeFinished()
+ }
+
+ private fun KairosTestScope.getSubscriptionCallback():
+ SubscriptionManager.OnSubscriptionsChangedListener =
+ argumentCaptor<SubscriptionManager.OnSubscriptionsChangedListener>()
+ .apply {
+ verify(subscriptionManager).addOnSubscriptionsChangedListener(any(), capture())
+ }
+ .lastValue
+
+ private fun KairosTestScope.getDemoModeCallback(): DemoMode =
+ argumentCaptor<DemoMode>()
+ .apply { verify(demoModeController).addCallback(capture()) }
+ .lastValue
+
+ companion object {
+ private const val SUB_1_ID = 1
+ private const val SUB_1_NAME = "Carrier $SUB_1_ID"
+ private val SUB_1: SubscriptionInfo = mock {
+ on { subscriptionId } doReturn SUB_1_ID
+ on { carrierName } doReturn SUB_1_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = SUB_1_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_2_ID = 2
+ private const val SUB_2_NAME = "Carrier $SUB_2_ID"
+ private val SUB_2: SubscriptionInfo = mock {
+ on { subscriptionId } doReturn SUB_2_ID
+ on { carrierName } doReturn SUB_2_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ carrierName = SUB_2_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt
new file mode 100644
index 000000000000..99cc93d6dc30
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionKairosParameterizedTest.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright (C) 2024 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.data.repository.demo
+
+import android.telephony.Annotation
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import androidx.test.filters.SmallTest
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoModeMobileConnectionDataSourceKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fake
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.wifiDataSource
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.stub
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4
+import platform.test.runner.parameterized.Parameters
+
+/**
+ * Parameterized test for all of the common values of [FakeNetworkEventModel]. This test simply
+ * verifies that passing the given model to [DemoMobileConnectionsRepositoryKairos] results in the
+ * correct flows emitting from the given connection.
+ */
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(ParameterizedAndroidJunit4::class)
+internal class DemoMobileConnectionKairosParameterizedTest(private val testCase: TestCase) :
+ SysuiTestCase() {
+
+ private val Kosmos.fakeWifiEventFlow by Fixture { MutableStateFlow<FakeWifiEventModel?>(null) }
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ wifiDataSource.stub { on { wifiEvents } doReturn fakeWifiEventFlow }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun demoNetworkData() = runTest {
+ val underTest by
+ demoMobileConnectionsRepositoryKairos.mobileConnectionsBySubId
+ .map { it[subId] }
+ .collectLastValue()
+ val networkModel =
+ FakeNetworkEventModel.Mobile(
+ level = testCase.level,
+ dataType = testCase.dataType,
+ subId = testCase.subId,
+ carrierId = testCase.carrierId,
+ inflateStrength = testCase.inflateStrength,
+ activity = testCase.activity,
+ carrierNetworkChange = testCase.carrierNetworkChange,
+ roaming = testCase.roaming,
+ name = "demo name",
+ slice = testCase.slice,
+ )
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(networkModel)
+ assertConnection(underTest!!, networkModel)
+ }
+
+ private suspend fun KairosTestScope.assertConnection(
+ conn: DemoMobileConnectionRepositoryKairos,
+ model: FakeNetworkEventModel,
+ ) {
+ when (model) {
+ is FakeNetworkEventModel.Mobile -> {
+ kairos.transact {
+ assertThat(conn.subId).isEqualTo(model.subId)
+ assertThat(conn.cdmaLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.primaryLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.dataActivityDirection.sample())
+ .isEqualTo(
+ (model.activity ?: DATA_ACTIVITY_NONE).toMobileDataActivityModel()
+ )
+ assertThat(conn.carrierNetworkChangeActive.sample())
+ .isEqualTo(model.carrierNetworkChange)
+ assertThat(conn.isRoaming.sample()).isEqualTo(model.roaming)
+ assertThat(conn.networkName.sample())
+ .isEqualTo(NetworkNameModel.IntentDerived(model.name))
+ assertThat(conn.carrierName.sample())
+ .isEqualTo(
+ NetworkNameModel.SubscriptionDerived("${model.name} ${model.subId}")
+ )
+ assertThat(conn.hasPrioritizedNetworkCapabilities.sample())
+ .isEqualTo(model.slice)
+ assertThat(conn.isNonTerrestrial.sample()).isEqualTo(model.ntn)
+
+ // TODO(b/261029387): check these once we start handling them
+ assertThat(conn.isEmergencyOnly.sample()).isFalse()
+ assertThat(conn.isGsm.sample()).isFalse()
+ assertThat(conn.dataConnectionState.sample())
+ .isEqualTo(DataConnectionState.Connected)
+ }
+ }
+ // MobileDisabled isn't combinatorial in nature, and is tested in
+ // DemoMobileConnectionsRepositoryTest.kt
+ else -> {}
+ }
+ }
+
+ /** Matches [FakeNetworkEventModel] */
+ internal data class TestCase(
+ val level: Int,
+ val dataType: SignalIcon.MobileIconGroup,
+ val subId: Int,
+ val carrierId: Int,
+ val inflateStrength: Boolean,
+ @Annotation.DataActivityType val activity: Int,
+ val carrierNetworkChange: Boolean,
+ val roaming: Boolean,
+ val name: String,
+ val slice: Boolean,
+ val ntn: Boolean,
+ ) {
+ override fun toString(): String {
+ return "INPUT(level=$level, " +
+ "dataType=${dataType.name}, " +
+ "subId=$subId, " +
+ "carrierId=$carrierId, " +
+ "inflateStrength=$inflateStrength, " +
+ "activity=$activity, " +
+ "carrierNetworkChange=$carrierNetworkChange, " +
+ "roaming=$roaming, " +
+ "name=$name," +
+ "slice=$slice" +
+ "ntn=$ntn)"
+ }
+
+ // Convenience for iterating test data and creating new cases
+ fun modifiedBy(
+ level: Int? = null,
+ dataType: SignalIcon.MobileIconGroup? = null,
+ subId: Int? = null,
+ carrierId: Int? = null,
+ inflateStrength: Boolean? = null,
+ @Annotation.DataActivityType activity: Int? = null,
+ carrierNetworkChange: Boolean? = null,
+ roaming: Boolean? = null,
+ name: String? = null,
+ slice: Boolean? = null,
+ ntn: Boolean? = null,
+ ): TestCase =
+ TestCase(
+ level = level ?: this.level,
+ dataType = dataType ?: this.dataType,
+ subId = subId ?: this.subId,
+ carrierId = carrierId ?: this.carrierId,
+ inflateStrength = inflateStrength ?: this.inflateStrength,
+ activity = activity ?: this.activity,
+ carrierNetworkChange = carrierNetworkChange ?: this.carrierNetworkChange,
+ roaming = roaming ?: this.roaming,
+ name = name ?: this.name,
+ slice = slice ?: this.slice,
+ ntn = ntn ?: this.ntn,
+ )
+ }
+
+ companion object {
+ private val subId = 1
+
+ private val booleanList = listOf(true, false)
+ private val levels = listOf(0, 1, 2, 3)
+ private val dataTypes =
+ listOf(
+ TelephonyIcons.THREE_G,
+ TelephonyIcons.LTE,
+ TelephonyIcons.FOUR_G,
+ TelephonyIcons.NR_5G,
+ TelephonyIcons.NR_5G_PLUS,
+ )
+ private val carrierIds = listOf(1, 10, 100)
+ private val inflateStrength = booleanList
+ private val activity =
+ listOf(
+ TelephonyManager.DATA_ACTIVITY_NONE,
+ TelephonyManager.DATA_ACTIVITY_IN,
+ TelephonyManager.DATA_ACTIVITY_OUT,
+ TelephonyManager.DATA_ACTIVITY_INOUT,
+ )
+ private val carrierNetworkChange = booleanList
+ // false first so the base case doesn't have roaming set (more common)
+ private val roaming = listOf(false, true)
+ private val names = listOf("name 1", "name 2")
+ private val slice = listOf(false, true)
+ private val ntn = listOf(false, true)
+
+ @Parameters(name = "{0}") @JvmStatic fun data() = testData()
+
+ /**
+ * Generate some test data. For the sake of convenience, we'll parameterize only non-null
+ * network event data. So given the lists of test data:
+ * ```
+ * list1 = [1, 2, 3]
+ * list2 = [false, true]
+ * list3 = [a, b, c]
+ * ```
+ *
+ * We'll generate test cases for:
+ *
+ * Test (1, false, a) Test (2, false, a) Test (3, false, a) Test (1, true, a) Test (1,
+ * false, b) Test (1, false, c)
+ *
+ * NOTE: this is not a combinatorial product of all of the possible sets of parameters.
+ * Since this test is built to exercise demo mode, the general approach is to define a
+ * fully-formed "base case", and from there to make sure to use every valid parameter once,
+ * by defining the rest of the test cases against the base case. Specific use-cases can be
+ * added to the non-parameterized test, or manually below the generated test cases.
+ */
+ private fun testData(): List<TestCase> {
+ val testSet = mutableSetOf<TestCase>()
+
+ val baseCase =
+ TestCase(
+ levels.first(),
+ dataTypes.first(),
+ subId,
+ carrierIds.first(),
+ inflateStrength.first(),
+ activity.first(),
+ carrierNetworkChange.first(),
+ roaming.first(),
+ names.first(),
+ slice.first(),
+ ntn.first(),
+ )
+
+ val tail =
+ sequenceOf(
+ levels.map { baseCase.modifiedBy(level = it) },
+ dataTypes.map { baseCase.modifiedBy(dataType = it) },
+ carrierIds.map { baseCase.modifiedBy(carrierId = it) },
+ inflateStrength.map { baseCase.modifiedBy(inflateStrength = it) },
+ activity.map { baseCase.modifiedBy(activity = it) },
+ carrierNetworkChange.map { baseCase.modifiedBy(carrierNetworkChange = it) },
+ roaming.map { baseCase.modifiedBy(roaming = it) },
+ names.map { baseCase.modifiedBy(name = it) },
+ slice.map { baseCase.modifiedBy(slice = it) },
+ ntn.map { baseCase.modifiedBy(ntn = it) },
+ )
+ .flatten()
+
+ testSet.add(baseCase)
+ tail.toCollection(testSet)
+
+ return testSet.toList()
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt
new file mode 100644
index 000000000000..503d561a2234
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairosTest.kt
@@ -0,0 +1,465 @@
+/*
+ * Copyright (C) 2024 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.data.repository.demo
+
+import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demoModeMobileConnectionDataSourceKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fake
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.wifiDataSource
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import junit.framework.Assert
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.stub
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class DemoMobileConnectionsRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.fakeWifiEventFlow by
+ Kosmos.Fixture { MutableStateFlow<FakeWifiEventModel?>(null) }
+
+ private val Kosmos.underTest
+ get() = demoMobileConnectionsRepositoryKairos
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ wifiDataSource.stub { on { wifiEvents } doReturn fakeWifiEventFlow }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun isDefault_defaultsToTrue() = runTest {
+ underTest
+ val isDefault = kairos.transact { underTest.mobileIsDefault.sample() }
+ assertThat(isDefault).isTrue()
+ }
+
+ @Test
+ fun validated_defaultsToTrue() = runTest {
+ underTest
+ val isValidated = kairos.transact { underTest.defaultConnectionIsValidated.sample() }
+ assertThat(isValidated).isTrue()
+ }
+
+ @Test
+ fun networkEvent_createNewSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(1)
+ }
+
+ @Test
+ fun wifiCarrierMergedEvent_createNewSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5)
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(5)
+ }
+
+ @Test
+ fun networkEvent_reusesSubscriptionWhenSameId() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(1)
+
+ // Second network event comes in with the same subId, does not create a new subscription
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 2)
+ )
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(1)
+ }
+
+ @Test
+ fun wifiCarrierMergedEvent_reusesSubscriptionWhenSameId() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ assertThat(latest).isEmpty()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5, level = 1)
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(5)
+
+ // Second network event comes in with the same subId, does not create a new subscription
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5, level = 2)
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!!.first().subscriptionId).isEqualTo(5)
+ }
+
+ @Test
+ fun multipleSubscriptions() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 2))
+
+ assertThat(latest).hasSize(2)
+ }
+
+ @Test
+ fun mobileSubscriptionAndCarrierMergedSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 5)
+
+ assertThat(latest).hasSize(2)
+ }
+
+ @Test
+ fun multipleMobileSubscriptionsAndCarrierMergedSubscription() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 1))
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(validMobileEvent(subId = 2))
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 3)
+
+ assertThat(latest).hasSize(3)
+ }
+
+ @Test
+ fun mobileDisabledEvent_disablesConnection_subIdSpecified_singleConn() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(MobileDisabled(subId = 1))
+
+ assertThat(latest).hasSize(0)
+ }
+
+ @Test
+ fun mobileDisabledEvent_disablesConnection_subIdNotSpecified_singleConn() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ MobileDisabled(subId = null)
+ )
+
+ assertThat(latest).hasSize(0)
+ }
+
+ @Test
+ fun mobileDisabledEvent_disablesConnection_subIdSpecified_multipleConn() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+
+ assertThat(latest).hasSize(1)
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 2, level = 1)
+ )
+
+ assertThat(latest).hasSize(2)
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(MobileDisabled(subId = 2))
+
+ assertThat(latest).hasSize(1)
+ }
+
+ @Test
+ fun mobileDisabledEvent_subIdNotSpecified_multipleConn_ignoresCommand() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 1, level = 1)
+ )
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 2, level = 1)
+ )
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ MobileDisabled(subId = null)
+ )
+
+ assertThat(latest).hasSize(2)
+ }
+
+ @Test
+ fun wifiNetworkUpdatesToDisabled_carrierMergedConnectionRemoved() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 1)
+
+ assertThat(latest).hasSize(1)
+
+ fakeWifiEventFlow.value = FakeWifiEventModel.WifiDisabled
+
+ assertThat(latest).isEmpty()
+ }
+
+ @Test
+ fun wifiNetworkUpdatesToActive_carrierMergedConnectionRemoved() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ fakeWifiEventFlow.value = validCarrierMergedEvent(subId = 1)
+
+ assertThat(latest).hasSize(1)
+
+ fakeWifiEventFlow.value =
+ FakeWifiEventModel.Wifi(level = 1, activity = 0, ssid = null, validated = true)
+
+ assertThat(latest).isEmpty()
+ }
+
+ @Test
+ fun mobileSubUpdatesToCarrierMerged_onlyOneConnection() = runTest {
+ val latestSubsList by underTest.subscriptions.collectLastValue()
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(
+ validMobileEvent(subId = 3, level = 2)
+ )
+ assertThat(latestSubsList).hasSize(1)
+
+ val carrierMergedEvent = validCarrierMergedEvent(subId = 3, level = 1)
+ fakeWifiEventFlow.value = carrierMergedEvent
+ assertThat(latestSubsList).hasSize(1)
+ val connection = connections!!.find { it.subId == 3 }!!
+ assertCarrierMergedConnection(connection, carrierMergedEvent)
+ }
+
+ @Test
+ fun mobileSubUpdatesToCarrierMergedThenBack_hasOldMobileData() = runTest {
+ val latestSubsList by underTest.subscriptions.collectLastValue()
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ val mobileEvent = validMobileEvent(subId = 3, level = 2)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(mobileEvent)
+ assertThat(latestSubsList).hasSize(1)
+
+ val carrierMergedEvent = validCarrierMergedEvent(subId = 3, level = 1)
+ fakeWifiEventFlow.value = carrierMergedEvent
+ assertThat(latestSubsList).hasSize(1)
+ var connection = connections!!.find { it.subId == 3 }!!
+ assertCarrierMergedConnection(connection, carrierMergedEvent)
+
+ // WHEN the carrier merged is removed
+ fakeWifiEventFlow.value =
+ FakeWifiEventModel.Wifi(level = 4, activity = 0, ssid = null, validated = true)
+
+ assertThat(latestSubsList).hasSize(1)
+ assertThat(connections).hasSize(1)
+
+ // THEN the subId=3 connection goes back to the mobile information
+ connection = connections!!.find { it.subId == 3 }!!
+ assertConnection(connection, mobileEvent)
+ }
+
+ @Test
+ fun demoConnection_singleSubscription() = runTest {
+ var currentEvent: FakeNetworkEventModel = validMobileEvent(subId = 1)
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent)
+
+ assertThat(connections).hasSize(1)
+ val connection1 = connections!!.first()
+
+ assertConnection(connection1, currentEvent)
+
+ // Exercise the whole api
+
+ currentEvent = validMobileEvent(subId = 1, level = 2)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent)
+ assertConnection(connection1, currentEvent)
+ }
+
+ @Test
+ fun demoConnection_twoConnections_updateSecond_noAffectOnFirst() = runTest {
+ var currentEvent1 = validMobileEvent(subId = 1)
+ var connection1: DemoMobileConnectionRepositoryKairos? = null
+ var currentEvent2 = validMobileEvent(subId = 2)
+ var connection2: DemoMobileConnectionRepositoryKairos? = null
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent2)
+ assertThat(connections).hasSize(2)
+ connections!!.forEach {
+ when (it.subId) {
+ 1 -> connection1 = it
+ 2 -> connection2 = it
+ else -> Assert.fail("Unexpected subscription")
+ }
+ }
+
+ assertConnection(connection1!!, currentEvent1)
+ assertConnection(connection2!!, currentEvent2)
+
+ // WHEN the event changes for connection 2, it updates, and connection 1 stays the same
+ currentEvent2 = validMobileEvent(subId = 2, activity = DATA_ACTIVITY_INOUT)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent2)
+ assertConnection(connection1!!, currentEvent1)
+ assertConnection(connection2!!, currentEvent2)
+
+ // and vice versa
+ currentEvent1 = validMobileEvent(subId = 1, inflateStrength = true)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ assertConnection(connection1!!, currentEvent1)
+ assertConnection(connection2!!, currentEvent2)
+ }
+
+ @Test
+ fun demoConnection_twoConnections_updateCarrierMerged_noAffectOnFirst() = runTest {
+ var currentEvent1 = validMobileEvent(subId = 1)
+ var connection1: DemoMobileConnectionRepositoryKairos? = null
+ var currentEvent2 = validCarrierMergedEvent(subId = 2)
+ var connection2: DemoMobileConnectionRepositoryKairos? = null
+ val connections by underTest.mobileConnectionsBySubId.map { it.values }.collectLastValue()
+
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ fakeWifiEventFlow.value = currentEvent2
+ assertThat(connections).hasSize(2)
+ connections!!.forEach {
+ when (it.subId) {
+ 1 -> connection1 = it
+ 2 -> connection2 = it
+ else -> Assert.fail("Unexpected subscription")
+ }
+ }
+
+ assertConnection(connection1!!, currentEvent1)
+ assertCarrierMergedConnection(connection2!!, currentEvent2)
+
+ // WHEN the event changes for connection 2, it updates, and connection 1 stays the same
+ currentEvent2 = validCarrierMergedEvent(subId = 2, level = 4)
+ fakeWifiEventFlow.value = currentEvent2
+ assertConnection(connection1!!, currentEvent1)
+ assertCarrierMergedConnection(connection2!!, currentEvent2)
+
+ // and vice versa
+ currentEvent1 = validMobileEvent(subId = 1, inflateStrength = true)
+ demoModeMobileConnectionDataSourceKairos.fake.mobileEvents.emit(currentEvent1)
+ assertConnection(connection1!!, currentEvent1)
+ assertCarrierMergedConnection(connection2!!, currentEvent2)
+ }
+
+ @Test
+ fun demoIsNotInEcmState() = runTest {
+ underTest
+ assertThat(kairos.transact { underTest.isInEcmMode.sample() }).isFalse()
+ }
+
+ private suspend fun KairosTestScope.assertConnection(
+ conn: DemoMobileConnectionRepositoryKairos,
+ model: FakeNetworkEventModel,
+ ) {
+ when (model) {
+ is FakeNetworkEventModel.Mobile -> {
+ kairos.transact {
+ assertThat(conn.subId).isEqualTo(model.subId)
+ assertThat(conn.cdmaLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.primaryLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.dataActivityDirection.sample())
+ .isEqualTo(
+ (model.activity ?: DATA_ACTIVITY_NONE).toMobileDataActivityModel()
+ )
+ assertThat(conn.carrierNetworkChangeActive.sample())
+ .isEqualTo(model.carrierNetworkChange)
+ assertThat(conn.isRoaming.sample()).isEqualTo(model.roaming)
+ assertThat(conn.networkName.sample())
+ .isEqualTo(NetworkNameModel.IntentDerived(model.name))
+ assertThat(conn.carrierName.sample())
+ .isEqualTo(
+ NetworkNameModel.SubscriptionDerived("${model.name} ${model.subId}")
+ )
+ assertThat(conn.hasPrioritizedNetworkCapabilities.sample())
+ .isEqualTo(model.slice)
+ assertThat(conn.isNonTerrestrial.sample()).isEqualTo(model.ntn)
+
+ // TODO(b/261029387) check these once we start handling them
+ assertThat(conn.isEmergencyOnly.sample()).isFalse()
+ assertThat(conn.isGsm.sample()).isFalse()
+ assertThat(conn.dataConnectionState.sample())
+ .isEqualTo(DataConnectionState.Connected)
+ }
+ }
+ else -> {}
+ }
+ }
+
+ private suspend fun KairosTestScope.assertCarrierMergedConnection(
+ conn: DemoMobileConnectionRepositoryKairos,
+ model: FakeWifiEventModel.CarrierMerged,
+ ) {
+ kairos.transact {
+ assertThat(conn.subId).isEqualTo(model.subscriptionId)
+ assertThat(conn.cdmaLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.primaryLevel.sample()).isEqualTo(model.level)
+ assertThat(conn.carrierNetworkChangeActive.sample()).isEqualTo(false)
+ assertThat(conn.isRoaming.sample()).isEqualTo(false)
+ assertThat(conn.isEmergencyOnly.sample()).isFalse()
+ assertThat(conn.isGsm.sample()).isFalse()
+ assertThat(conn.dataConnectionState.sample()).isEqualTo(DataConnectionState.Connected)
+ assertThat(conn.hasPrioritizedNetworkCapabilities.sample()).isFalse()
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt
new file mode 100644
index 000000000000..1838d13b793a
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairosTest.kt
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.telephony.TelephonyManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.fakeWifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class CarrierMergedConnectionRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.underTest by ActivatedKairosFixture {
+ CarrierMergedConnectionRepositoryKairos(
+ subId = SUB_ID,
+ tableLogBuffer = logcatTableLogBuffer(this),
+ telephonyManager = telephonyManager,
+ wifiRepository = wifiRepository,
+ isInEcmMode = stateOf(false),
+ )
+ }
+
+ private val Kosmos.telephonyManager: TelephonyManager by Fixture {
+ mock {
+ on { subscriptionId } doReturn SUB_ID
+ on { simOperatorName } doReturn ""
+ }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ testKosmos().run {
+ useUnconfinedTestDispatcher()
+ runKairosTest { block() }
+ }
+
+ @Test
+ fun inactiveWifi_isDefault() = runTest {
+ val latestConnState by underTest.dataConnectionState.collectLastValue()
+ val latestNetType by underTest.resolvedNetworkType.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.Inactive())
+
+ assertThat(latestConnState).isEqualTo(DataConnectionState.Disconnected)
+ assertThat(latestNetType).isNotEqualTo(ResolvedNetworkType.CarrierMergedNetworkType)
+ }
+
+ @Test
+ fun activeWifi_isDefault() = runTest {
+ val latestConnState by underTest.dataConnectionState.collectLastValue()
+ val latestNetType by underTest.resolvedNetworkType.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.Active.of(level = 1))
+
+ assertThat(latestConnState).isEqualTo(DataConnectionState.Disconnected)
+ assertThat(latestNetType).isNotEqualTo(ResolvedNetworkType.CarrierMergedNetworkType)
+ }
+
+ @Test
+ fun carrierMergedWifi_isValidAndFieldsComeFromWifiNetwork() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+
+ assertThat(latest).isEqualTo(3)
+ }
+
+ @Test
+ fun activity_comesFromWifiActivity() = runTest {
+ val latest by underTest.dataActivityDirection.collectLastValue()
+
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+ fakeWifiRepository.setWifiActivity(
+ DataActivityModel(hasActivityIn = true, hasActivityOut = false)
+ )
+
+ assertThat(latest!!.hasActivityIn).isTrue()
+ assertThat(latest!!.hasActivityOut).isFalse()
+
+ fakeWifiRepository.setWifiActivity(
+ DataActivityModel(hasActivityIn = false, hasActivityOut = true)
+ )
+
+ assertThat(latest!!.hasActivityIn).isFalse()
+ assertThat(latest!!.hasActivityOut).isTrue()
+ }
+
+ @Test
+ fun carrierMergedWifi_wrongSubId_isDefault() = runTest {
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+ val latestType by underTest.resolvedNetworkType.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID + 10, level = 3)
+ )
+
+ assertThat(latestLevel).isNotEqualTo(3)
+ assertThat(latestType).isNotEqualTo(ResolvedNetworkType.CarrierMergedNetworkType)
+ }
+
+ // This scenario likely isn't possible, but write a test for it anyway
+ @Test
+ fun carrierMergedButNotEnabled_isDefault() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+ fakeWifiRepository.setIsWifiEnabled(false)
+
+ assertThat(latest).isNotEqualTo(3)
+ }
+
+ // This scenario likely isn't possible, but write a test for it anyway
+ @Test
+ fun carrierMergedButWifiNotDefault_isDefault() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+ fakeWifiRepository.setIsWifiDefault(false)
+
+ assertThat(latest).isNotEqualTo(3)
+ }
+
+ @Test
+ fun numberOfLevels_comesFromCarrierMerged() = runTest {
+ val latest by underTest.numberOfLevels.collectLastValue()
+
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(
+ subscriptionId = SUB_ID,
+ level = 1,
+ numberOfLevels = 6,
+ )
+ )
+
+ assertThat(latest).isEqualTo(6)
+ }
+
+ @Test
+ fun dataEnabled_matchesWifiEnabled() = runTest {
+ val latest by underTest.dataEnabled.collectLastValue()
+
+ fakeWifiRepository.setIsWifiEnabled(true)
+ assertThat(latest).isTrue()
+
+ fakeWifiRepository.setIsWifiEnabled(false)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun cdmaRoaming_alwaysFalse() = runTest {
+ val latest by underTest.cdmaRoaming.collectLastValue()
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun networkName_usesSimOperatorNameAsInitial() = runTest {
+ telephonyManager.stub { on { simOperatorName } doReturn "Test SIM name" }
+
+ val latest by underTest.networkName.collectLastValue()
+
+ assertThat(latest).isEqualTo(NetworkNameModel.SimDerived("Test SIM name"))
+ }
+
+ @Test
+ fun networkName_updatesOnNetworkUpdate() = runTest {
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+
+ telephonyManager.stub { on { simOperatorName } doReturn "Test SIM name" }
+
+ val latest by underTest.networkName.collectLastValue()
+
+ assertThat(latest).isEqualTo(NetworkNameModel.SimDerived("Test SIM name"))
+
+ telephonyManager.stub { on { simOperatorName } doReturn "New SIM name" }
+ fakeWifiRepository.setWifiNetwork(
+ WifiNetworkModel.CarrierMerged.of(subscriptionId = SUB_ID, level = 3)
+ )
+
+ assertThat(latest).isEqualTo(NetworkNameModel.SimDerived("New SIM name"))
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_alwaysTrue() = runTest {
+ val latest by underTest.isAllowedDuringAirplaneMode.collectLastValue()
+
+ assertThat(latest).isTrue()
+ }
+
+ private companion object {
+ const val SUB_ID = 123
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt
new file mode 100644
index 000000000000..858bb095df93
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairosTest.kt
@@ -0,0 +1,544 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.os.PersistableBundle
+import android.telephony.ServiceState
+import android.telephony.SignalStrength
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.activated
+import com.android.systemui.flags.Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.BuildSpec
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Companion.COL_EMERGENCY
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Companion.COL_OPERATOR
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_PRIMARY_LEVEL
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.getTelephonyCallbackForType
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.fakeWifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import java.io.PrintWriter
+import java.io.StringWriter
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+/**
+ * This repo acts as a dispatcher to either the `typical` or `carrier merged` versions of the
+ * repository interface it's switching on. These tests just need to verify that the entire interface
+ * properly switches over when the value of `isCarrierMerged` changes.
+ */
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class FullMobileConnectionRepositoryKairosTest : SysuiTestCase() {
+ private val Kosmos.fakeMobileRepo by Fixture {
+ FakeMobileConnectionRepositoryKairos(SUB_ID, kairos, mobileLogger)
+ }
+
+ private val Kosmos.fakeCarrierMergedRepo by Fixture {
+ FakeMobileConnectionRepositoryKairos(SUB_ID, kairos, mobileLogger).apply {
+ // Mimicks the real carrier merged repository
+ isAllowedDuringAirplaneMode.setValue(true)
+ }
+ }
+
+ private var Kosmos.mobileRepo: MobileConnectionRepositoryKairos by Fixture { fakeMobileRepo }
+ private var Kosmos.carrierMergedRepoSpec:
+ BuildSpec<MobileConnectionRepositoryKairos> by Fixture {
+ buildSpec { fakeCarrierMergedRepo }
+ }
+
+ private val Kosmos.mobileLogger by Fixture { logcatTableLogBuffer(this, "TestName") }
+
+ private val Kosmos.underTest by ActivatedKairosFixture {
+ FullMobileConnectionRepositoryKairos(
+ SUB_ID,
+ mobileLogger,
+ mobileRepo,
+ carrierMergedRepoSpec,
+ isCarrierMerged,
+ )
+ }
+
+ private val Kosmos.subscriptionModel by Fixture {
+ MutableState(
+ kairos,
+ SubscriptionModel(
+ subscriptionId = SUB_ID,
+ carrierName = DEFAULT_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ ),
+ )
+ }
+
+ private val Kosmos.isCarrierMerged by Fixture { MutableState(kairos, false) }
+
+ // Use a real config, with no overrides
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_ID, PersistableBundle())
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ fakeFeatureFlagsClassic.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun startingIsCarrierMerged_usesCarrierMergedInitially() = runTest {
+ val carrierMergedOperatorName = "Carrier Merged Operator"
+ val nonCarrierMergedName = "Non-carrier-merged"
+
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(carrierMergedOperatorName)
+ fakeMobileRepo.operatorAlphaShort.setValue(nonCarrierMergedName)
+
+ isCarrierMerged.setValue(true)
+
+ val activeRepo by underTest.activeRepo.collectLastValue()
+ val operatorAlphaShort by underTest.operatorAlphaShort.collectLastValue()
+
+ assertThat(activeRepo).isEqualTo(fakeCarrierMergedRepo)
+ assertThat(operatorAlphaShort).isEqualTo(carrierMergedOperatorName)
+ }
+
+ @Test
+ fun startingNotCarrierMerged_usesTypicalInitially() = runTest {
+ val carrierMergedOperatorName = "Carrier Merged Operator"
+ val nonCarrierMergedName = "Typical Operator"
+
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(carrierMergedOperatorName)
+ fakeMobileRepo.operatorAlphaShort.setValue(nonCarrierMergedName)
+ isCarrierMerged.setValue(false)
+
+ assertThat(underTest.activeRepo.collectLastValue().value).isEqualTo(fakeMobileRepo)
+ assertThat(underTest.operatorAlphaShort.collectLastValue().value)
+ .isEqualTo(nonCarrierMergedName)
+ }
+
+ @Test
+ fun activeRepo_matchesIsCarrierMerged() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latest by underTest.activeRepo.collectLastValue()
+
+ isCarrierMerged.setValue(true)
+
+ assertThat(latest).isEqualTo(fakeCarrierMergedRepo)
+
+ isCarrierMerged.setValue(false)
+
+ assertThat(latest).isEqualTo(fakeMobileRepo)
+
+ isCarrierMerged.setValue(true)
+
+ assertThat(latest).isEqualTo(fakeCarrierMergedRepo)
+ }
+
+ @Test
+ fun connectionInfo_getsUpdatesFromRepo_carrierMerged() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latestName by underTest.operatorAlphaShort.collectLastValue()
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+
+ isCarrierMerged.setValue(true)
+
+ val operator1 = "Carrier Merged Operator"
+ val level1 = 1
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(operator1)
+ fakeCarrierMergedRepo.primaryLevel.setValue(level1)
+
+ assertThat(latestName).isEqualTo(operator1)
+ assertThat(latestLevel).isEqualTo(level1)
+
+ val operator2 = "Carrier Merged Operator #2"
+ val level2 = 2
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(operator2)
+ fakeCarrierMergedRepo.primaryLevel.setValue(level2)
+
+ assertThat(latestName).isEqualTo(operator2)
+ assertThat(latestLevel).isEqualTo(level2)
+
+ val operator3 = "Carrier Merged Operator #3"
+ val level3 = 3
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(operator3)
+ fakeCarrierMergedRepo.primaryLevel.setValue(level3)
+
+ assertThat(latestName).isEqualTo(operator3)
+ assertThat(latestLevel).isEqualTo(level3)
+ }
+
+ @Test
+ fun connectionInfo_getsUpdatesFromRepo_mobile() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latestName by underTest.operatorAlphaShort.collectLastValue()
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+
+ isCarrierMerged.setValue(false)
+
+ val operator1 = "Typical Merged Operator"
+ val level1 = 1
+ fakeMobileRepo.operatorAlphaShort.setValue(operator1)
+ fakeMobileRepo.primaryLevel.setValue(level1)
+
+ assertThat(latestName).isEqualTo(operator1)
+ assertThat(latestLevel).isEqualTo(level1)
+
+ val operator2 = "Typical Merged Operator #2"
+ val level2 = 2
+ fakeMobileRepo.operatorAlphaShort.setValue(operator2)
+ fakeMobileRepo.primaryLevel.setValue(level2)
+
+ assertThat(latestName).isEqualTo(operator2)
+ assertThat(latestLevel).isEqualTo(level2)
+
+ val operator3 = "Typical Merged Operator #3"
+ val level3 = 3
+ fakeMobileRepo.operatorAlphaShort.setValue(operator3)
+ fakeMobileRepo.primaryLevel.setValue(level3)
+
+ assertThat(latestName).isEqualTo(operator3)
+ assertThat(latestLevel).isEqualTo(level3)
+ }
+
+ @Test
+ fun connectionInfo_updatesWhenCarrierMergedUpdates() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latestName by underTest.operatorAlphaShort.collectLastValue()
+ val latestLevel by underTest.primaryLevel.collectLastValue()
+
+ val carrierMergedOperator = "Carrier Merged Operator"
+ val carrierMergedLevel = 4
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(carrierMergedOperator)
+ fakeCarrierMergedRepo.primaryLevel.setValue(carrierMergedLevel)
+
+ val mobileName = "Typical Operator"
+ val mobileLevel = 2
+ fakeMobileRepo.operatorAlphaShort.setValue(mobileName)
+ fakeMobileRepo.primaryLevel.setValue(mobileLevel)
+
+ // Start with the mobile info
+ assertThat(latestName).isEqualTo(mobileName)
+ assertThat(latestLevel).isEqualTo(mobileLevel)
+
+ // WHEN isCarrierMerged is set to true
+ isCarrierMerged.setValue(true)
+
+ // THEN the carrier merged info is used
+ assertThat(latestName).isEqualTo(carrierMergedOperator)
+ assertThat(latestLevel).isEqualTo(carrierMergedLevel)
+
+ val newCarrierMergedName = "New CM Operator"
+ val newCarrierMergedLevel = 0
+ fakeCarrierMergedRepo.operatorAlphaShort.setValue(newCarrierMergedName)
+ fakeCarrierMergedRepo.primaryLevel.setValue(newCarrierMergedLevel)
+
+ assertThat(latestName).isEqualTo(newCarrierMergedName)
+ assertThat(latestLevel).isEqualTo(newCarrierMergedLevel)
+
+ // WHEN isCarrierMerged is set to false
+ isCarrierMerged.setValue(false)
+
+ // THEN the typical info is used
+ assertThat(latestName).isEqualTo(mobileName)
+ assertThat(latestLevel).isEqualTo(mobileLevel)
+
+ val newMobileName = "New MobileOperator"
+ val newMobileLevel = 3
+ fakeMobileRepo.operatorAlphaShort.setValue(newMobileName)
+ fakeMobileRepo.primaryLevel.setValue(newMobileLevel)
+
+ assertThat(latestName).isEqualTo(newMobileName)
+ assertThat(latestLevel).isEqualTo(newMobileLevel)
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_updatesWhenCarrierMergedUpdates() = runTest {
+ isCarrierMerged.setValue(false)
+
+ val latest by underTest.isAllowedDuringAirplaneMode.collectLastValue()
+
+ assertThat(latest).isFalse()
+
+ isCarrierMerged.setValue(true)
+
+ assertThat(latest).isTrue()
+
+ isCarrierMerged.setValue(false)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun connectionInfo_logging_notCarrierMerged_getsUpdates() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ isCarrierMerged.setValue(false)
+
+ // Stand-up activated repository
+ underTest
+
+ // WHEN we set up some mobile connection info
+ val serviceState = ServiceState()
+ serviceState.setOperatorName("longName", "OpTypical", "1")
+ serviceState.isEmergencyOnly = true
+ getTelephonyCallbackForType<TelephonyCallback.ServiceStateListener>(telephonyManager)
+ .onServiceStateChanged(serviceState)
+
+ // THEN it's logged to the buffer
+ assertThat(dumpBuffer()).contains("$COL_OPERATOR${BUFFER_SEPARATOR}OpTypical")
+ assertThat(dumpBuffer()).contains("$COL_EMERGENCY${BUFFER_SEPARATOR}true")
+
+ // WHEN we update mobile connection info
+ val serviceState2 = ServiceState()
+ serviceState2.setOperatorName("longName", "OpDiff", "1")
+ serviceState2.isEmergencyOnly = false
+ getTelephonyCallbackForType<TelephonyCallback.ServiceStateListener>(telephonyManager)
+ .onServiceStateChanged(serviceState2)
+
+ // THEN the updates are logged
+ assertThat(dumpBuffer()).contains("$COL_OPERATOR${BUFFER_SEPARATOR}OpDiff")
+ assertThat(dumpBuffer()).contains("$COL_EMERGENCY${BUFFER_SEPARATOR}false")
+ }
+
+ @Test
+ fun connectionInfo_logging_carrierMerged_getsUpdates() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ isCarrierMerged.setValue(true)
+
+ // Stand-up activated repository
+ underTest
+
+ // WHEN we set up carrier merged info
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 3))
+
+ // THEN the carrier merged info is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}3")
+
+ // WHEN we update the info
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 1))
+
+ // THEN the updates are logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}1")
+ }
+
+ @Test
+ fun connectionInfo_logging_updatesWhenCarrierMergedUpdates() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ isCarrierMerged.setValue(false)
+
+ // Stand-up activated repository
+ underTest
+
+ // WHEN we set up some mobile connection info
+ val cb =
+ getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>(telephonyManager)
+ cb.onSignalStrengthsChanged(mock(stubOnly = true) { on { level } doReturn 1 })
+
+ // THEN it's logged to the buffer
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}1")
+
+ // WHEN isCarrierMerged is set to true
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 3))
+ isCarrierMerged.setValue(true)
+
+ // THEN the carrier merged info is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}3")
+
+ // WHEN the carrier merge network is updated
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 4))
+
+ // THEN the new level is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}4")
+
+ // WHEN isCarrierMerged is set to false
+ isCarrierMerged.setValue(false)
+
+ // THEN the typical info is logged
+ // Note: Since our first logs also had the typical info, we need to search the log
+ // contents for after our carrier merged level log.
+ val fullBuffer = dumpBuffer()
+ val carrierMergedContentIndex = fullBuffer.indexOf("${BUFFER_SEPARATOR}4")
+ val bufferAfterCarrierMerged = fullBuffer.substring(carrierMergedContentIndex)
+ assertThat(bufferAfterCarrierMerged).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}1")
+
+ // WHEN the normal network is updated
+ cb.onSignalStrengthsChanged(mock(stubOnly = true) { on { level } doReturn 0 })
+
+ // THEN the new level is logged
+ assertThat(dumpBuffer()).contains("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}0")
+ }
+
+ @Test
+ fun connectionInfo_logging_doesNotLogUpdatesForNotActiveRepo() = runTest {
+ // SETUP: Use real repositories to verify the diffing still works. (See b/267501739.)
+ val telephonyManager: TelephonyManager = mock {
+ on { simOperatorName } doReturn ""
+ on { subscriptionId } doReturn SUB_ID
+ }
+ fakeWifiRepository.setIsWifiEnabled(true)
+ fakeWifiRepository.setIsWifiDefault(true)
+ mobileRepo = createRealMobileRepo(telephonyManager)
+ carrierMergedRepoSpec = realCarrierMergedRepo(telephonyManager)
+
+ // WHEN isCarrierMerged = false
+ isCarrierMerged.setValue(false)
+
+ // Stand-up activated repository
+ underTest
+
+ fun setSignalLevel(newLevel: Int) {
+ val signalStrength =
+ mock<SignalStrength>(stubOnly = true) { on { level } doReturn newLevel }
+ argumentCaptor<TelephonyCallback>()
+ .apply { verify(telephonyManager).registerTelephonyCallback(any(), capture()) }
+ .allValues
+ .asSequence()
+ .filterIsInstance<TelephonyCallback.SignalStrengthsListener>()
+ .forEach { it.onSignalStrengthsChanged(signalStrength) }
+ }
+
+ // WHEN we set up some mobile connection info
+ setSignalLevel(1)
+
+ // THEN updates to the carrier merged level aren't logged
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 4))
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}4")
+
+ fakeWifiRepository.setWifiNetwork(WifiNetworkModel.CarrierMerged.of(SUB_ID, level = 3))
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}3")
+
+ // WHEN isCarrierMerged is set to true
+ isCarrierMerged.setValue(true)
+
+ // THEN updates to the normal level aren't logged
+ setSignalLevel(5)
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}5")
+
+ setSignalLevel(6)
+ assertThat(dumpBuffer()).doesNotContain("$COL_PRIMARY_LEVEL${BUFFER_SEPARATOR}6")
+ }
+
+ private fun KairosTestScope.createRealMobileRepo(
+ telephonyManager: TelephonyManager
+ ): MobileConnectionRepositoryKairosImpl =
+ MobileConnectionRepositoryKairosImpl(
+ subId = SUB_ID,
+ context = context,
+ subscriptionModel = subscriptionModel,
+ defaultNetworkName = DEFAULT_NAME_MODEL,
+ networkNameSeparator = SEP,
+ connectivityManager = mock(stubOnly = true),
+ telephonyManager = telephonyManager,
+ systemUiCarrierConfig = systemUiCarrierConfig,
+ broadcastDispatcher = fakeBroadcastDispatcher,
+ mobileMappingsProxy = mock(stubOnly = true),
+ bgDispatcher = testDispatcher,
+ logger = mock(stubOnly = true),
+ tableLogBuffer = mobileLogger,
+ flags = featureFlagsClassic,
+ )
+ .activated()
+
+ private fun Kosmos.realCarrierMergedRepo(
+ telephonyManager: TelephonyManager
+ ): BuildSpec<CarrierMergedConnectionRepositoryKairos> = buildSpec {
+ activated {
+ CarrierMergedConnectionRepositoryKairos(
+ subId = SUB_ID,
+ tableLogBuffer = mobileLogger,
+ telephonyManager = telephonyManager,
+ wifiRepository = wifiRepository,
+ isInEcmMode = stateOf(false),
+ )
+ }
+ }
+
+ private fun Kosmos.dumpBuffer(): String {
+ val outputWriter = StringWriter()
+ mobileLogger.dump(PrintWriter(outputWriter), arrayOf())
+ return outputWriter.toString()
+ }
+
+ private companion object {
+ const val SUB_ID = 42
+ private const val DEFAULT_NAME = "default name"
+ private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+ private const val SEP = "-"
+ private const val BUFFER_SEPARATOR = "|"
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt
new file mode 100644
index 000000000000..32fc35934bd6
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosTest.kt
@@ -0,0 +1,1228 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.connectivityManager
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.telephony.AccessNetworkConstants.TRANSPORT_TYPE_WLAN
+import android.telephony.AccessNetworkConstants.TRANSPORT_TYPE_WWAN
+import android.telephony.CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL
+import android.telephony.CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL
+import android.telephony.NetworkRegistrationInfo
+import android.telephony.NetworkRegistrationInfo.DOMAIN_PS
+import android.telephony.NetworkRegistrationInfo.REGISTRATION_STATE_DENIED
+import android.telephony.NetworkRegistrationInfo.REGISTRATION_STATE_HOME
+import android.telephony.ServiceState
+import android.telephony.ServiceState.STATE_IN_SERVICE
+import android.telephony.ServiceState.STATE_OUT_OF_SERVICE
+import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyCallback.CarrierRoamingNtnListener
+import android.telephony.TelephonyCallback.DataActivityListener
+import android.telephony.TelephonyCallback.DisplayInfoListener
+import android.telephony.TelephonyCallback.ServiceStateListener
+import android.telephony.TelephonyDisplayInfo
+import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA
+import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.DATA_ACTIVITY_DORMANT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_IN
+import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import android.telephony.TelephonyManager.DATA_ACTIVITY_OUT
+import android.telephony.TelephonyManager.DATA_CONNECTED
+import android.telephony.TelephonyManager.DATA_CONNECTING
+import android.telephony.TelephonyManager.DATA_DISCONNECTED
+import android.telephony.TelephonyManager.DATA_DISCONNECTING
+import android.telephony.TelephonyManager.DATA_HANDOVER_IN_PROGRESS
+import android.telephony.TelephonyManager.DATA_SUSPENDED
+import android.telephony.TelephonyManager.DATA_UNKNOWN
+import android.telephony.TelephonyManager.ERI_OFF
+import android.telephony.TelephonyManager.ERI_ON
+import android.telephony.TelephonyManager.EXTRA_CARRIER_ID
+import android.telephony.TelephonyManager.EXTRA_DATA_SPN
+import android.telephony.TelephonyManager.EXTRA_PLMN
+import android.telephony.TelephonyManager.EXTRA_SHOW_PLMN
+import android.telephony.TelephonyManager.EXTRA_SHOW_SPN
+import android.telephony.TelephonyManager.EXTRA_SPN
+import android.telephony.TelephonyManager.EXTRA_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager.NETWORK_TYPE_LTE
+import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN
+import android.telephony.telephonyManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.Flags
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.flags.Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO
+import com.android.systemui.flags.fake
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.LogcatEchoTrackerAlways
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.tableLogBufferFactory
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfigWithOverride
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.signalStrength
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.telephonyDisplayInfo
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileConnectionRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.underTest by ActivatedKairosFixture {
+ MobileConnectionRepositoryKairosImpl(
+ SUB_1_ID,
+ context,
+ subscriptionModel,
+ DEFAULT_NAME_MODEL,
+ SEP,
+ connectivityManager,
+ telephonyManager,
+ systemUiCarrierConfig,
+ fakeBroadcastDispatcher,
+ mobileMappingsProxy,
+ testDispatcher,
+ logger,
+ tableLogger,
+ featureFlagsClassic,
+ )
+ }
+
+ private val Kosmos.logger: MobileInputLogger by Fixture {
+ MobileInputLogger(LogBuffer("test_buffer", 1, LogcatEchoTrackerAlways()))
+ }
+
+ private val Kosmos.tableLogger: TableLogBuffer by Fixture {
+ tableLogBufferFactory.getOrCreate("test_buffer", 1)
+ }
+
+ private val Kosmos.context: Context by Fixture { mock() }
+
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
+
+ private val Kosmos.subscriptionModel: MutableState<SubscriptionModel?> by Fixture {
+ MutableState(
+ kairos,
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = DEFAULT_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ ),
+ )
+ }
+
+ private val kosmos =
+ testKosmos().apply {
+ useUnconfinedTestDispatcher()
+ featureFlagsClassic.fake.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+ telephonyManager.stub { on { subscriptionId } doReturn SUB_1_ID }
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun emergencyOnly() = runTest {
+ val latest by underTest.isEmergencyOnly.collectLastValue()
+
+ val serviceState = ServiceState().apply { isEmergencyOnly = true }
+
+ getTelephonyCallbackForType<ServiceStateListener>().onServiceStateChanged(serviceState)
+
+ assertThat(latest).isEqualTo(true)
+ }
+
+ @Test
+ fun emergencyOnly_toggles() = runTest {
+ val latest by underTest.isEmergencyOnly.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<ServiceStateListener>()
+ callback.onServiceStateChanged(ServiceState().apply { isEmergencyOnly = true })
+
+ assertThat(latest).isTrue()
+
+ callback.onServiceStateChanged(ServiceState().apply { isEmergencyOnly = false })
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun cdmaLevelUpdates() = runTest {
+ val latest by underTest.cdmaLevel.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>()
+ var strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(2)
+
+ // gsmLevel updates, no change to cdmaLevel
+ strength = signalStrength(gsmLevel = 3, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(2)
+ }
+
+ @Test
+ fun gsmLevelUpdates() = runTest {
+ val latest by underTest.primaryLevel.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>()
+ var strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(1)
+
+ strength = signalStrength(gsmLevel = 3, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isEqualTo(3)
+ }
+
+ @Test
+ fun isGsm() = runTest {
+ val latest by underTest.isGsm.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.SignalStrengthsListener>()
+ var strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = true)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isTrue()
+
+ strength = signalStrength(gsmLevel = 1, cdmaLevel = 2, isGsm = false)
+ callback.onSignalStrengthsChanged(strength)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun dataConnectionState_connected() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_CONNECTED, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Connected)
+ }
+
+ @Test
+ fun dataConnectionState_connecting() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_CONNECTING, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Connecting)
+ }
+
+ @Test
+ fun dataConnectionState_disconnected() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_DISCONNECTED, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Disconnected)
+ }
+
+ @Test
+ fun dataConnectionState_disconnecting() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_DISCONNECTING, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Disconnecting)
+ }
+
+ @Test
+ fun dataConnectionState_suspended() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_SUSPENDED, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Suspended)
+ }
+
+ @Test
+ fun dataConnectionState_handoverInProgress() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_HANDOVER_IN_PROGRESS, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.HandoverInProgress)
+ }
+
+ @Test
+ fun dataConnectionState_unknown() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(DATA_UNKNOWN, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Unknown)
+ }
+
+ @Test
+ fun dataConnectionState_invalid() = runTest {
+ val latest by underTest.dataConnectionState.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataConnectionStateListener>()
+ callback.onDataConnectionStateChanged(45, 200 /* unused */)
+
+ assertThat(latest).isEqualTo(DataConnectionState.Invalid)
+ }
+
+ @Test
+ fun dataActivity() = runTest {
+ val latest by underTest.dataActivityDirection.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<DataActivityListener>()
+ callback.onDataActivity(DATA_ACTIVITY_INOUT)
+
+ assertThat(latest).isEqualTo(DATA_ACTIVITY_INOUT.toMobileDataActivityModel())
+ }
+
+ @Test
+ fun carrierId_initialValueCaptured() = runTest {
+ whenever(telephonyManager.simCarrierId).thenReturn(1234)
+
+ val latest by underTest.carrierId.collectLastValue()
+
+ assertThat(latest).isEqualTo(1234)
+ }
+
+ @Test
+ fun carrierId_updatesOnBroadcast() = runTest {
+ whenever(telephonyManager.simCarrierId).thenReturn(1234)
+
+ val latest by underTest.carrierId.collectLastValue()
+
+ fakeBroadcastDispatcher.sendIntentToMatchingReceiversOnly(
+ context,
+ carrierIdIntent(carrierId = 4321),
+ )
+
+ assertThat(latest).isEqualTo(4321)
+ }
+
+ @Test
+ fun carrierNetworkChange() = runTest {
+ val latest by underTest.carrierNetworkChangeActive.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.CarrierNetworkListener>()
+ callback.onCarrierNetworkChange(true)
+
+ assertThat(latest).isEqualTo(true)
+ }
+
+ @Test
+ fun networkType_default() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val expected = UnknownNetworkType
+
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun networkType_unknown_hasCorrectKey() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val ti =
+ telephonyDisplayInfo(
+ networkType = NETWORK_TYPE_UNKNOWN,
+ overrideNetworkType = NETWORK_TYPE_UNKNOWN,
+ )
+
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = UnknownNetworkType
+ assertThat(latest).isEqualTo(expected)
+ assertThat(latest!!.lookupKey).isEqualTo(MobileMappings.toIconKey(NETWORK_TYPE_UNKNOWN))
+ }
+
+ @Test
+ fun networkType_updatesUsingDefault() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val overrideType = OVERRIDE_NETWORK_TYPE_NONE
+ val type = NETWORK_TYPE_LTE
+ val ti = telephonyDisplayInfo(networkType = type, overrideNetworkType = overrideType)
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = DefaultNetworkType(mobileMappingsProxy.toIconKey(type))
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun networkType_updatesUsingOverride() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val type = OVERRIDE_NETWORK_TYPE_LTE_CA
+ val ti = telephonyDisplayInfo(networkType = type, overrideNetworkType = type)
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(type))
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun networkType_unknownNetworkWithOverride_usesOverrideKey() = runTest {
+ val latest by underTest.resolvedNetworkType.collectLastValue()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DisplayInfoListener>()
+ val unknown = NETWORK_TYPE_UNKNOWN
+ val type = OVERRIDE_NETWORK_TYPE_LTE_CA
+ val ti = telephonyDisplayInfo(unknown, type)
+ callback.onDisplayInfoChanged(ti)
+
+ val expected = OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(type))
+ assertThat(latest).isEqualTo(expected)
+ }
+
+ @Test
+ fun dataEnabled_initial_false() = runTest {
+ whenever(telephonyManager.isDataConnectionAllowed).thenReturn(false)
+
+ val latest by underTest.dataEnabled.collectLastValue()
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isDataEnabled_tracksTelephonyCallback() = runTest {
+ val latest by underTest.dataEnabled.collectLastValue()
+
+ whenever(telephonyManager.isDataConnectionAllowed).thenReturn(false)
+ assertThat(latest).isFalse()
+
+ val callback = getTelephonyCallbackForType<TelephonyCallback.DataEnabledListener>()
+
+ callback.onDataEnabledChanged(true, 1)
+ assertThat(latest).isTrue()
+
+ callback.onDataEnabledChanged(false, 1)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun numberOfLevels_isDefault() = runTest {
+ val latest by underTest.numberOfLevels.collectLastValue()
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
+ }
+
+ @Test
+ fun roaming_cdma_queriesTelephonyManager() = runTest {
+ val latest by underTest.cdmaRoaming.collectLastValue()
+
+ val cb = getTelephonyCallbackForType<ServiceStateListener>()
+
+ // CDMA roaming is off, GSM roaming is on
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_OFF)
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = true })
+
+ assertThat(latest).isFalse()
+
+ // CDMA roaming is on, GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_ON)
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = false })
+
+ assertThat(latest).isTrue()
+ }
+
+ /**
+ * [TelephonyManager.getCdmaEnhancedRoamingIndicatorDisplayNumber] returns -1 if the service is
+ * not running or if there is an error while retrieving the cdma ERI
+ */
+ @Test
+ fun cdmaRoaming_ignoresNegativeOne() = runTest {
+ val latest by underTest.cdmaRoaming.collectLastValue()
+
+ val serviceState = ServiceState()
+ serviceState.roaming = false
+
+ val cb = getTelephonyCallbackForType<ServiceStateListener>()
+
+ // CDMA roaming is unavailable (-1), GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(-1)
+ cb.onServiceStateChanged(serviceState)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun roaming_gsm_queriesDisplayInfo_viaDisplayInfo() = runTest {
+ // GIVEN flag is true
+ featureFlagsClassic.fake.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+
+ val latest by underTest.isRoaming.collectLastValue()
+
+ val cb = getTelephonyCallbackForType<DisplayInfoListener>()
+
+ // CDMA roaming is off, GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_OFF)
+ cb.onDisplayInfoChanged(TelephonyDisplayInfo(NETWORK_TYPE_LTE, NETWORK_TYPE_UNKNOWN, false))
+
+ assertThat(latest).isFalse()
+
+ // CDMA roaming is off, GSM roaming is on
+ cb.onDisplayInfoChanged(TelephonyDisplayInfo(NETWORK_TYPE_LTE, NETWORK_TYPE_UNKNOWN, true))
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun roaming_gsm_queriesDisplayInfo_viaServiceState() = runTest {
+ // GIVEN flag is false
+ featureFlagsClassic.fake.set(ROAMING_INDICATOR_VIA_DISPLAY_INFO, false)
+
+ val latest by underTest.isRoaming.collectLastValue()
+
+ val cb = getTelephonyCallbackForType<ServiceStateListener>()
+
+ // CDMA roaming is off, GSM roaming is off
+ whenever(telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber).thenReturn(ERI_OFF)
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = false })
+
+ assertThat(latest).isFalse()
+
+ // CDMA roaming is off, GSM roaming is on
+ cb.onServiceStateChanged(ServiceState().also { it.roaming = true })
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun activity_updatesFromCallback() = runTest {
+ val latest by underTest.dataActivityDirection.collectLastValue()
+
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+
+ val cb = getTelephonyCallbackForType<DataActivityListener>()
+ cb.onDataActivity(DATA_ACTIVITY_IN)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = true, hasActivityOut = false))
+
+ cb.onDataActivity(DATA_ACTIVITY_OUT)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = true))
+
+ cb.onDataActivity(DATA_ACTIVITY_INOUT)
+ assertThat(latest).isEqualTo(DataActivityModel(hasActivityIn = true, hasActivityOut = true))
+
+ cb.onDataActivity(DATA_ACTIVITY_NONE)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+
+ cb.onDataActivity(DATA_ACTIVITY_DORMANT)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+
+ cb.onDataActivity(1234)
+ assertThat(latest)
+ .isEqualTo(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+ }
+
+ @Test
+ fun networkNameForSubId_updates() = runTest {
+ val latest by underTest.carrierName.collectLastValue()
+
+ subscriptionModel.setValue(
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = DEFAULT_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ )
+
+ assertThat(latest?.name).isEqualTo(DEFAULT_NAME)
+
+ val updatedName = "Derived Carrier"
+ subscriptionModel.setValue(
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = updatedName,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ )
+
+ assertThat(latest?.name).isEqualTo(updatedName)
+ }
+
+ @Test
+ fun networkNameForSubId_defaultWhenSubscriptionModelNull() = runTest {
+ val latest by underTest.carrierName.collectLastValue()
+
+ subscriptionModel.setValue(null)
+
+ assertThat(latest?.name).isEqualTo(DEFAULT_NAME)
+ }
+
+ @Test
+ fun networkName_default() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usesBroadcastInfo_returnsDerived() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // spnIntent() sets all values to true and test strings
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usesBroadcastInfo_returnsDerived_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // spnIntent() sets all values to true and test strings
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastNotForThisSubId_keepsOldValue() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ // WHEN an intent with a different subId is sent
+ val wrongSubIntent = spnIntent(subId = 101)
+
+ captor.lastValue.onReceive(context, wrongSubIntent)
+
+ // THEN the previous intent's name is still used
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastNotForThisSubId_keepsOldValue_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ // WHEN an intent with a different subId is sent
+ val wrongSubIntent = spnIntent(subId = 101)
+
+ captor.lastValue.onReceive(context, wrongSubIntent)
+
+ // THEN the previous intent's name is still used
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastHasNoData_updatesToDefault() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ val intentWithoutInfo = spnIntent(showSpn = false, showPlmn = false)
+
+ captor.lastValue.onReceive(context, intentWithoutInfo)
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_broadcastHasNoData_updatesToDefault_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+
+ val intentWithoutInfo = spnIntent(showSpn = false, showPlmn = false)
+
+ captor.lastValue.onReceive(context, intentWithoutInfo)
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usingEagerStrategy_retainsNameBetweenSubscribers() = runTest {
+ // Use the [StateFlow.value] getter so we can prove that the collection happens
+ // even when there is no [Job]
+
+ // Starts out default
+ val latest by underTest.networkName.collectLastValue()
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // The value is still there despite no active subscribers
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_usingEagerStrategy_retainsNameBetweenSubscribers_flagOff() = runTest {
+ // Use the [StateFlow.value] getter so we can prove that the collection happens
+ // even when there is no [Job]
+
+ // Starts out default
+ val latest by underTest.networkName.collectLastValue()
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+ val intent = spnIntent()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ captor.lastValue.onReceive(context, intent)
+
+ // The value is still there despite no active subscribers
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_allFieldsSet_prioritizesDataSpnOverSpn() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_spnAndPlmn_fallbackToSpnWhenNullDataSpn() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_allFieldsSet_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNotNull_showSpn_spnNull_dataSpnNotNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = null,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNotNull_showSpn_spnNotNull_dataSpnNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$SPN"))
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNotNull_showSpn_spnNull_dataSpnNotNull_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = null,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN$SEP$DATA_SPN"))
+ }
+
+ @Test
+ fun networkName_showPlmn_noShowSPN() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = false,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = PLMN,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$PLMN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$DATA_SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn_dataSpnNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$SPN"))
+ }
+
+ @Test
+ @EnableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn_bothSpnNull() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = null,
+ dataSpn = null,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+ }
+
+ @Test
+ @DisableFlags(Flags.FLAG_STATUS_BAR_SWITCH_TO_SPN_FROM_DATA_SPN)
+ fun networkName_showPlmn_plmnNull_showSpn_flagOff() = runTest {
+ val latest by underTest.networkName.collectLastValue()
+ val captor = argumentCaptor<BroadcastReceiver>()
+ verify(context).registerReceiver(captor.capture(), any())
+ val intent =
+ spnIntent(
+ subId = SUB_1_ID,
+ showSpn = true,
+ spn = SPN,
+ dataSpn = DATA_SPN,
+ showPlmn = true,
+ plmn = null,
+ )
+ captor.lastValue.onReceive(context, intent)
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived("$DATA_SPN"))
+ }
+
+ @Test
+ fun operatorAlphaShort_tracked() = runTest {
+ val latest by underTest.operatorAlphaShort.collectLastValue()
+
+ val shortName = "short name"
+ val serviceState = ServiceState()
+ serviceState.setOperatorName(
+ /* longName */ "long name",
+ /* shortName */ shortName,
+ /* numeric */ "12345",
+ )
+
+ getTelephonyCallbackForType<ServiceStateListener>().onServiceStateChanged(serviceState)
+
+ assertThat(latest).isEqualTo(shortName)
+ }
+
+ @Test
+ fun isInService_notIwlan() = runTest {
+ val latest by underTest.isInService.collectLastValue()
+
+ val nriInService =
+ NetworkRegistrationInfo.Builder()
+ .setDomain(DOMAIN_PS)
+ .setTransportType(TRANSPORT_TYPE_WWAN)
+ .setRegistrationState(REGISTRATION_STATE_HOME)
+ .build()
+
+ getTelephonyCallbackForType<ServiceStateListener>()
+ .onServiceStateChanged(
+ ServiceState().also {
+ it.voiceRegState = STATE_IN_SERVICE
+ it.addNetworkRegistrationInfo(nriInService)
+ }
+ )
+
+ assertThat(latest).isTrue()
+
+ getTelephonyCallbackForType<ServiceStateListener>()
+ .onServiceStateChanged(
+ ServiceState().also {
+ it.voiceRegState = STATE_OUT_OF_SERVICE
+ it.addNetworkRegistrationInfo(nriInService)
+ }
+ )
+ assertThat(latest).isTrue()
+
+ val nriNotInService =
+ NetworkRegistrationInfo.Builder()
+ .setDomain(DOMAIN_PS)
+ .setTransportType(TRANSPORT_TYPE_WWAN)
+ .setRegistrationState(REGISTRATION_STATE_DENIED)
+ .build()
+ getTelephonyCallbackForType<ServiceStateListener>()
+ .onServiceStateChanged(
+ ServiceState().also {
+ it.voiceRegState = STATE_OUT_OF_SERVICE
+ it.addNetworkRegistrationInfo(nriNotInService)
+ }
+ )
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isInService_isIwlan_voiceOutOfService_dataInService() = runTest {
+ val latest by underTest.isInService.collectLastValue()
+
+ val iwlanData =
+ NetworkRegistrationInfo.Builder()
+ .setDomain(DOMAIN_PS)
+ .setTransportType(TRANSPORT_TYPE_WLAN)
+ .setRegistrationState(REGISTRATION_STATE_HOME)
+ .build()
+ val serviceState =
+ ServiceState().also {
+ it.voiceRegState = STATE_OUT_OF_SERVICE
+ it.addNetworkRegistrationInfo(iwlanData)
+ }
+
+ getTelephonyCallbackForType<ServiceStateListener>().onServiceStateChanged(serviceState)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isNonTerrestrial_updatesFromCallback0() = runTest {
+ val latest by underTest.isNonTerrestrial.collectLastValue()
+
+ // Starts out false
+ assertThat(latest).isFalse()
+
+ val callback = getTelephonyCallbackForType<CarrierRoamingNtnListener>()
+
+ callback.onCarrierRoamingNtnModeChanged(true)
+ assertThat(latest).isTrue()
+
+ callback.onCarrierRoamingNtnModeChanged(false)
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun numberOfLevels_usesCarrierConfig() = runTest {
+ val latest by underTest.numberOfLevels.collectLastValue()
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ )
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS + 1)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ )
+
+ assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
+ }
+
+ @Test
+ fun inflateSignalStrength_usesCarrierConfig() = runTest {
+ val latest by underTest.inflateSignalStrength.collectLastValue()
+
+ assertThat(latest).isEqualTo(false)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ )
+
+ assertThat(latest).isEqualTo(true)
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ )
+
+ assertThat(latest).isEqualTo(false)
+ }
+
+ @Test
+ fun allowNetworkSliceIndicator_exposesCarrierConfigValue() = runTest {
+ val latest by underTest.allowNetworkSliceIndicator.collectLastValue()
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, true)
+ )
+
+ assertThat(latest).isTrue()
+
+ systemUiCarrierConfig.processNewCarrierConfig(
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, false)
+ )
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_alwaysFalse() = runTest {
+ val latest by underTest.isAllowedDuringAirplaneMode.collectLastValue()
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun hasPrioritizedCaps_defaultFalse() = runTest {
+ // stand up under-test to kick-off activation
+ underTest
+
+ assertThat(kairos.transact { underTest.hasPrioritizedNetworkCapabilities.sample() })
+ .isFalse()
+ }
+
+ @Test
+ fun hasPrioritizedCaps_trueWhenAvailable() = runTest {
+ val latest by underTest.hasPrioritizedNetworkCapabilities.collectLastValue()
+
+ val callback: NetworkCallback =
+ argumentCaptor<NetworkCallback>()
+ .apply { verify(connectivityManager).registerNetworkCallback(any(), capture()) }
+ .lastValue
+
+ callback.onAvailable(mock())
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasPrioritizedCaps_becomesFalseWhenNetworkLost() = runTest {
+ val latest by underTest.hasPrioritizedNetworkCapabilities.collectLastValue()
+
+ val callback: NetworkCallback =
+ argumentCaptor<NetworkCallback>()
+ .apply { verify(connectivityManager).registerNetworkCallback(any(), capture()) }
+ .lastValue
+
+ callback.onAvailable(mock())
+
+ assertThat(latest).isTrue()
+
+ callback.onLost(mock())
+
+ assertThat(latest).isFalse()
+ }
+
+ private inline fun <reified T> Kosmos.getTelephonyCallbackForType(): T {
+ return MobileTelephonyHelpers.getTelephonyCallbackForType(telephonyManager)
+ }
+
+ private fun carrierIdIntent(subId: Int = SUB_1_ID, carrierId: Int): Intent =
+ Intent(TelephonyManager.ACTION_SUBSCRIPTION_CARRIER_IDENTITY_CHANGED).apply {
+ putExtra(EXTRA_SUBSCRIPTION_ID, subId)
+ putExtra(EXTRA_CARRIER_ID, carrierId)
+ }
+
+ private fun spnIntent(
+ subId: Int = SUB_1_ID,
+ showSpn: Boolean = true,
+ spn: String? = SPN,
+ dataSpn: String? = DATA_SPN,
+ showPlmn: Boolean = true,
+ plmn: String? = PLMN,
+ ): Intent =
+ Intent(TelephonyManager.ACTION_SERVICE_PROVIDERS_UPDATED).apply {
+ putExtra(EXTRA_SUBSCRIPTION_INDEX, subId)
+ putExtra(EXTRA_SHOW_SPN, showSpn)
+ putExtra(EXTRA_SPN, spn)
+ putExtra(EXTRA_DATA_SPN, dataSpn)
+ putExtra(EXTRA_SHOW_PLMN, showPlmn)
+ putExtra(EXTRA_PLMN, plmn)
+ }
+
+ companion object {
+ private const val SUB_1_ID = 1
+
+ private const val DEFAULT_NAME = "Fake Mobile Network"
+ private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+ private const val SEP = "-"
+
+ private const val SPN = "testSpn"
+ private const val DATA_SPN = "testDataSpn"
+ private const val PLMN = "testPlmn"
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt
new file mode 100644
index 000000000000..e04a96eb3032
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosTest.kt
@@ -0,0 +1,1350 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.applicationContext
+import android.content.testableContext
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.connectivityManager
+import android.net.vcn.VcnTransportInfo
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.ParcelUuid
+import android.telephony.CarrierConfigManager
+import android.telephony.ServiceState
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.SubscriptionManager.OnSubscriptionsChangedListener
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.telephony.TelephonyCallback.ActiveDataSubscriptionIdListener
+import android.telephony.TelephonyCallback.EmergencyCallbackModeListener
+import android.telephony.TelephonyManager
+import android.telephony.telephonyManager
+import android.testing.TestableLooper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.telephony.PhoneConstants
+import com.android.keyguard.KeyguardUpdateMonitorCallback
+import com.android.keyguard.keyguardUpdateMonitor
+import com.android.settingslib.R
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.broadcast.broadcastDispatcher
+import com.android.systemui.broadcast.broadcastDispatcherContext
+import com.android.systemui.flags.Flags
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosTestScope
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.runKairosTest
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.connectivity.WifiPickerTrackerFactory
+import com.android.systemui.statusbar.pipeline.airplane.data.repository.airplaneModeRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileConnectionsRepositoryKairosImpl
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.getTelephonyCallbackForType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.subscriptionManager
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.subscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.fake
+import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlots
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepositoryImpl
+import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.prod.WifiRepositoryImpl
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.userRepository
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.time.FakeSystemClock
+import com.android.wifitrackerlib.MergedCarrierEntry
+import com.android.wifitrackerlib.WifiEntry
+import com.android.wifitrackerlib.WifiPickerTracker
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import java.util.UUID
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalKairosApi::class)
+@SmallTest
+// This is required because our [SubscriptionManager.OnSubscriptionsChangedListener] uses a looper
+// to run the callback and this makes the looper place nicely with TestScope etc.
+@TestableLooper.RunWithLooper
+@RunWith(AndroidJUnit4::class)
+class MobileConnectionsRepositoryKairosTest : SysuiTestCase() {
+
+ private val Kosmos.wifiManager: WifiManager by Fixture { mock {} }
+ private val Kosmos.wifiPickerTrackerFactory: WifiPickerTrackerFactory by Fixture {
+ mock {
+ on { create(any(), any(), wifiPickerTrackerCallback.capture(), any()) } doReturn
+ wifiPickerTracker
+ }
+ }
+ private val Kosmos.wifiPickerTracker: WifiPickerTracker by Fixture { mock {} }
+ private val Kosmos.wifiTableLogBuffer by Fixture { logcatTableLogBuffer(this, "wifiTableLog") }
+
+ private val mainExecutor = FakeExecutor(FakeSystemClock())
+ private val wifiLogBuffer = LogBuffer("wifi", maxSize = 100, logcatEchoTracker = mock())
+ private val wifiPickerTrackerCallback =
+ argumentCaptor<WifiPickerTracker.WifiPickerTrackerCallback>()
+ private val vcnTransportInfo = VcnTransportInfo.Builder().build()
+
+ private val Kosmos.underTest
+ get() = mobileConnectionsRepositoryKairosImpl
+
+ private val kosmos =
+ testKosmos().apply {
+ fakeFeatureFlagsClassic.set(Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO, true)
+ broadcastDispatcherContext = testableContext
+ connectivityRepository =
+ ConnectivityRepositoryImpl(
+ connectivityManager,
+ ConnectivitySlots(applicationContext),
+ applicationContext,
+ mock(),
+ mock(),
+ applicationCoroutineScope,
+ mock(),
+ )
+ wifiRepository =
+ WifiRepositoryImpl(
+ applicationContext,
+ userRepository,
+ applicationCoroutineScope,
+ mainExecutor,
+ testDispatcher,
+ wifiPickerTrackerFactory,
+ wifiManager,
+ wifiLogBuffer,
+ wifiTableLogBuffer,
+ )
+ subscriptionManager.stub {
+ // For convenience, set up the subscription info callbacks
+ on { getActiveSubscriptionInfo(anyInt()) } doAnswer
+ { invocation ->
+ when (invocation.getArgument(0) as Int) {
+ 1 -> SUB_1
+ 2 -> SUB_2
+ 3 -> SUB_3
+ 4 -> SUB_4
+ else -> null
+ }
+ }
+ }
+ telephonyManager.stub {
+ on { simOperatorName } doReturn ""
+ // Set up so the individual connection repositories
+ on { createForSubscriptionId(anyInt()) } doAnswer
+ { invocation ->
+ telephonyManager.stub {
+ on { subscriptionId } doReturn invocation.getArgument(0)
+ }
+ }
+ }
+ testScope.runCurrent()
+ }
+
+ private fun runTest(block: suspend KairosTestScope.() -> Unit) =
+ kosmos.run { runKairosTest { block() } }
+
+ @Test
+ fun testSubscriptions_initiallyEmpty() = runTest {
+ assertThat(underTest.subscriptions.collectLastValue().value)
+ .isEqualTo(listOf<SubscriptionModel>())
+ }
+
+ @Test
+ fun testSubscriptions_listUpdates() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2))
+ }
+
+ @Test
+ fun testSubscriptions_removingSub_updatesList() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ // WHEN 2 networks show up
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ // WHEN one network is removed
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ // THEN the subscriptions list represents the newest change
+ assertThat(latest).isEqualTo(listOf(MODEL_2))
+ }
+
+ @Test
+ fun subscriptions_subIsOnlyNtn_modelHasExclusivelyNtnTrue() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ val onlyNtnSub =
+ mock<SubscriptionInfo> {
+ on { isOnlyNonTerrestrialNetwork } doReturn true
+ on { subscriptionId } doReturn 45
+ on { groupUuid } doReturn GROUP_1
+ on { carrierName } doReturn "NTN only"
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(onlyNtnSub)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!![0].isExclusivelyNonTerrestrial).isTrue()
+ }
+
+ @Test
+ fun subscriptions_subIsNotOnlyNtn_modelHasExclusivelyNtnFalse() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ val notOnlyNtnSub =
+ mock<SubscriptionInfo> {
+ on { isOnlyNonTerrestrialNetwork } doReturn false
+ on { subscriptionId } doReturn 45
+ on { groupUuid } doReturn GROUP_1
+ on { carrierName } doReturn "NTN only"
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(notOnlyNtnSub)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).hasSize(1)
+ assertThat(latest!![0].isExclusivelyNonTerrestrial).isFalse()
+ }
+
+ @Test
+ fun testSubscriptions_carrierMergedOnly_listHasCarrierMerged() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_CM))
+ }
+
+ @Test
+ fun testSubscriptions_carrierMergedAndOther_listHasBothWithCarrierMergedLast() = runTest {
+ val latest by underTest.subscriptions.collectLastValue()
+
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2, SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(latest).isEqualTo(listOf(MODEL_1, MODEL_2, MODEL_CM))
+ }
+
+ @Test
+ fun testActiveDataSubscriptionId_initialValueIsNull() = runTest {
+ assertThat(underTest.activeMobileDataSubscriptionId.collectLastValue().value)
+ .isEqualTo(null)
+ }
+
+ @Test
+ fun testActiveDataSubscriptionId_updates() = runTest {
+ val active by underTest.activeMobileDataSubscriptionId.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+
+ assertThat(active).isEqualTo(SUB_2_ID)
+ }
+
+ @Test
+ fun activeSubId_nullIfInvalidSubIdIsReceived() = runTest {
+ val latest by underTest.activeMobileDataSubscriptionId.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+
+ assertThat(latest).isNotNull()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(INVALID_SUBSCRIPTION_ID)
+
+ assertThat(latest).isNull()
+ }
+
+ @Test
+ fun activeRepo_initiallyNull() = runTest {
+ assertThat(underTest.activeMobileDataRepository.collectLastValue().value).isNull()
+ }
+
+ @Test
+ fun activeRepo_updatesWithActiveDataId() = runTest {
+ val latest by underTest.activeMobileDataRepository.collectLastValue()
+ testScope.runCurrent()
+
+ // GIVEN the subscription list is then updated which includes the active data sub id
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+
+ assertThat(latest?.subId).isEqualTo(SUB_2_ID)
+ }
+
+ @Test
+ fun activeRepo_nullIfActiveDataSubIdBecomesInvalid() = runTest {
+ val latest by underTest.activeMobileDataRepository.collectLastValue()
+ testScope.runCurrent()
+
+ // GIVEN the subscription list is then updated which includes the active data sub id
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ assertThat(latest).isNotNull()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(INVALID_SUBSCRIPTION_ID)
+ testScope.runCurrent()
+
+ assertThat(latest).isNull()
+ }
+
+ @Test
+ /** Regression test for b/268146648. */
+ fun activeSubIdIsSetBeforeSubscriptionsAreUpdated_doesNotThrow() = runTest {
+ val activeRepo by underTest.activeMobileDataRepository.collectLastValue()
+ val subscriptions by underTest.subscriptions.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ assertThat(subscriptions).isEmpty()
+ assertThat(activeRepo).isNull()
+ }
+
+ @Test
+ fun getRepoForSubId_activeDataSubIdIsRequestedBeforeSubscriptionsUpdate() = runTest {
+ underTest
+
+ var latestActiveRepo: MobileConnectionRepositoryKairos? = null
+ testScope.backgroundScope.launch {
+ kairos.activateSpec {
+ underTest.activeMobileDataSubscriptionId
+ .combine(underTest.mobileConnectionsBySubId) { id, conns ->
+ id?.let { conns[id] }
+ }
+ .observe {
+ if (it != null) {
+ latestActiveRepo = it
+ }
+ }
+ }
+ }
+
+ val latestSubscriptions by underTest.subscriptions.collectLastValue()
+ testScope.runCurrent()
+
+ // Active data subscription id is sent, but no subscription change has been posted yet
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ // Subscriptions list is empty
+ assertThat(latestSubscriptions).isEmpty()
+
+ // getRepoForSubId does not throw
+ assertThat(latestActiveRepo).isNull()
+ }
+
+ @Test
+ fun activeDataSentBeforeSubscriptionList_subscriptionReusesActiveDataRepo() = runTest {
+ val activeRepo by underTest.activeMobileDataRepository.collectLastValue()
+ testScope.runCurrent()
+
+ // GIVEN active repo is updated before the subscription list updates
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_2_ID)
+ testScope.runCurrent()
+
+ assertThat(activeRepo).isNull()
+
+ // GIVEN the subscription list is then updated which includes the active data sub id
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+ testScope.runCurrent()
+
+ // WHEN requesting a connection repository for the subscription
+ val newRepo =
+ kairos.transact { underTest.mobileConnectionsBySubId.map { it[SUB_2_ID] }.sample() }
+
+ // THEN the newly request repo has been cached and reused
+ assertThat(activeRepo).isSameInstanceAs(newRepo)
+ }
+
+ @Test
+ fun testConnectionRepository_validSubId_isCached() = runTest {
+ underTest
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repo1 by underTest.mobileConnectionsBySubId.map { it[SUB_1_ID] }.collectLastValue()
+ val repo2 by underTest.mobileConnectionsBySubId.map { it[SUB_1_ID] }.collectLastValue()
+
+ assertThat(repo1).isNotNull()
+ assertThat(repo1).isSameInstanceAs(repo2)
+ }
+
+ @Test
+ fun testConnectionRepository_carrierMergedSubId_isCached() = runTest {
+ underTest
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, WIFI_NETWORK_CAPS_CM)
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repo1 by underTest.mobileConnectionsBySubId.map { it[SUB_CM_ID] }.collectLastValue()
+ val repo2 by underTest.mobileConnectionsBySubId.map { it[SUB_CM_ID] }.collectLastValue()
+
+ assertThat(repo1).isNotNull()
+ assertThat(repo1).isSameInstanceAs(repo2)
+ }
+
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ @Test
+ fun testDeviceEmergencyCallState_eagerlyChecksState() = runTest {
+ val latest by underTest.isDeviceEmergencyCallCapable.collectLastValue()
+
+ // Value starts out false
+ assertThat(latest).isFalse()
+ telephonyManager.stub { on { activeModemCount } doReturn 1 }
+ whenever(telephonyManager.getServiceStateForSlot(any())).thenAnswer { _ ->
+ ServiceState().apply { isEmergencyOnly = true }
+ }
+
+ // WHEN an appropriate intent gets sent out
+ val intent = serviceStateIntent(subId = -1)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+ testScope.runCurrent()
+
+ // THEN the repo's state is updated despite no listeners
+ assertThat(latest).isEqualTo(true)
+ }
+
+ @Test
+ fun testDeviceEmergencyCallState_aggregatesAcrossSlots_oneTrue() = runTest {
+ val latest by underTest.isDeviceEmergencyCallCapable.collectLastValue()
+
+ // GIVEN there are multiple slots
+ telephonyManager.stub { on { activeModemCount } doReturn 4 }
+ // GIVEN only one of them reports ECM
+ whenever(telephonyManager.getServiceStateForSlot(any())).thenAnswer { invocation ->
+ when (invocation.getArgument(0) as Int) {
+ 0 -> ServiceState().apply { isEmergencyOnly = false }
+ 1 -> ServiceState().apply { isEmergencyOnly = false }
+ 2 -> ServiceState().apply { isEmergencyOnly = true }
+ 3 -> ServiceState().apply { isEmergencyOnly = false }
+ else -> null
+ }
+ }
+
+ // GIVEN a broadcast goes out for the appropriate subID
+ val intent = serviceStateIntent(subId = -1)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+ testScope.runCurrent()
+
+ // THEN the device is in ECM, because one of the service states is
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun testDeviceEmergencyCallState_aggregatesAcrossSlots_allFalse() = runTest {
+ val latest by underTest.isDeviceEmergencyCallCapable.collectLastValue()
+
+ // GIVEN there are multiple slots
+ telephonyManager.stub { on { activeModemCount } doReturn 4 }
+ // GIVEN only one of them reports ECM
+ whenever(telephonyManager.getServiceStateForSlot(any())).thenAnswer { invocation ->
+ when (invocation.getArgument(0) as Int) {
+ 0 -> ServiceState().apply { isEmergencyOnly = false }
+ 1 -> ServiceState().apply { isEmergencyOnly = false }
+ 2 -> ServiceState().apply { isEmergencyOnly = false }
+ 3 -> ServiceState().apply { isEmergencyOnly = false }
+ else -> null
+ }
+ }
+
+ // GIVEN a broadcast goes out for the appropriate subID
+ val intent = serviceStateIntent(subId = -1)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+ testScope.runCurrent()
+
+ // THEN the device is in ECM, because one of the service states is
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun testConnectionCache_clearsInvalidSubscriptions() = runTest {
+ underTest
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repoCache by underTest.mobileConnectionsBySubId.collectLastValue()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID, SUB_2_ID)
+
+ // SUB_2 disappears
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID)
+ }
+
+ @Test
+ fun testConnectionCache_clearsInvalidSubscriptions_includingCarrierMerged() = runTest {
+ underTest
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, WIFI_NETWORK_CAPS_CM)
+ setWifiState(isCarrierMerged = true)
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2, SUB_CM)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repoCache by underTest.mobileConnectionsBySubId.collectLastValue()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID, SUB_2_ID, SUB_CM_ID)
+
+ // SUB_2 and SUB_CM disappear
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID)
+ }
+
+ /** Regression test for b/261706421 */
+ @Test
+ fun testConnectionsCache_clearMultipleSubscriptionsAtOnce_doesNotThrow() = runTest {
+ underTest
+
+ subscriptionManager.stub {
+ on { completeActiveSubscriptionInfoList } doReturn listOf(SUB_1, SUB_2)
+ }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ val repoCache by underTest.mobileConnectionsBySubId.collectLastValue()
+
+ assertThat(repoCache?.keys).containsExactly(SUB_1_ID, SUB_2_ID)
+
+ // All subscriptions disappear
+ subscriptionManager.stub { on { completeActiveSubscriptionInfoList } doReturn listOf() }
+ getSubscriptionCallback().onSubscriptionsChanged()
+
+ assertThat(repoCache).isEmpty()
+ }
+
+ @Test
+ fun testDefaultDataSubId_updatesOnBroadcast() = runTest {
+ val latest by underTest.defaultDataSubId.collectLastValue()
+
+ assertThat(latest).isEqualTo(null)
+
+ val intent2 =
+ Intent(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ .putExtra(PhoneConstants.SUBSCRIPTION_KEY, SUB_2_ID)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent2)
+
+ assertThat(latest).isEqualTo(SUB_2_ID)
+
+ val intent1 =
+ Intent(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ .putExtra(PhoneConstants.SUBSCRIPTION_KEY, SUB_1_ID)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent1)
+
+ assertThat(latest).isEqualTo(SUB_1_ID)
+ }
+
+ @Test
+ fun defaultDataSubId_fetchesInitialValueOnStart() = runTest {
+ subscriptionManagerProxy.fake.defaultDataSubId = 2
+ val latest by underTest.defaultDataSubId.collectLastValue()
+
+ assertThat(latest).isEqualTo(2)
+ }
+
+ @Test
+ fun mobileIsDefault_startsAsFalse() = runTest {
+ assertThat(underTest.mobileIsDefault.collectLastValue().value).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_capsHaveCellular_isDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun mobileIsDefault_capsDoNotHaveCellular_isNotDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn false
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_carrierMergedViaMobile_isDefault() = runTest {
+ val carrierMergedInfo = mock<WifiInfo> { on { isCarrierMerged } doReturn true }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun mobileIsDefault_wifiDefault_mobileNotDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_ethernetDefault_mobileNotDefault() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_ETHERNET) } doReturn true
+ }
+
+ val latest by underTest.mobileIsDefault.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaWifi_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaMobile_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ private fun KairosTestScope.newWifiNetwork(wifiInfo: WifiInfo): Network {
+ val network = mock<Network>()
+ val capabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn wifiInfo
+ }
+ connectivityManager.stub { on { getNetworkCapabilities(network) } doReturn capabilities }
+ return network
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaWifiWithVcnTransport_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val underlyingWifi = newWifiNetwork(carrierMergedInfo)
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn vcnTransportInfo
+ on { underlyingNetworks } doReturn listOf(underlyingWifi)
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_carrierMergedViaMobileWithVcnTransport_isTrue() = runTest {
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val underlyingWifi = newWifiNetwork(carrierMergedInfo)
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn vcnTransportInfo
+ on { underlyingNetworks } doReturn listOf(underlyingWifi)
+ }
+
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+ setWifiState(isCarrierMerged = true)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_isCarrierMergedViaUnderlyingWifi_isTrue() = runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ val underlyingNetwork = mock<Network>()
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+ val underlyingWifiCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+ connectivityManager.stub {
+ on { getNetworkCapabilities(underlyingNetwork) } doReturn underlyingWifiCapabilities
+ }
+
+ // WHEN the main capabilities have an underlying carrier merged network via WIFI
+ // transport and WifiInfo
+ val mainCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ on { underlyingNetworks } doReturn listOf(underlyingNetwork)
+ }
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, mainCapabilities)
+ setWifiState(isCarrierMerged = true)
+
+ // THEN there's a carrier merged connection
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun hasCarrierMergedConnection_isCarrierMergedViaUnderlyingCellular_isTrue() = runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ val underlyingCarrierMergedNetwork = mock<Network>()
+ val carrierMergedInfo =
+ mock<WifiInfo> {
+ on { isCarrierMerged } doReturn true
+ on { isPrimary } doReturn true
+ }
+
+ // The Wifi network that is under the VCN network
+ val physicalWifiNetwork = newWifiNetwork(carrierMergedInfo)
+
+ val underlyingCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn vcnTransportInfo
+ on { underlyingNetworks } doReturn listOf(physicalWifiNetwork)
+ }
+ connectivityManager.stub {
+ on { getNetworkCapabilities(underlyingCarrierMergedNetwork) } doReturn
+ underlyingCapabilities
+ }
+
+ // WHEN the main capabilities have an underlying carrier merged network via CELLULAR
+ // transport and VcnTransportInfo
+ val mainCapabilities =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ on { underlyingNetworks } doReturn listOf(underlyingCarrierMergedNetwork)
+ }
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, mainCapabilities)
+ setWifiState(isCarrierMerged = true)
+
+ // THEN there's a carrier merged connection
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun hasCarrierMergedConnection_defaultIsWifiNotCarrierMerged_wifiRepoIsCarrierMerged_isTrue() =
+ runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ // WHEN the default callback is TRANSPORT_WIFI but not carrier merged
+ val carrierMergedInfo = mock<WifiInfo> { on { isCarrierMerged } doReturn false }
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn carrierMergedInfo
+ }
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ // BUT the wifi repo has gotten updates that it *is* carrier merged
+ setWifiState(isCarrierMerged = true)
+
+ // THEN hasCarrierMergedConnection is true
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/278618530. */
+ @Test
+ fun hasCarrierMergedConnection_defaultIsCellular_wifiRepoIsCarrierMerged_isFalse() = runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ // WHEN the default callback is TRANSPORT_CELLULAR and not carrier merged
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ }
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ // BUT the wifi repo has gotten updates that it *is* carrier merged
+ setWifiState(isCarrierMerged = true)
+
+ // THEN hasCarrierMergedConnection is **false** (The default network being CELLULAR
+ // takes precedence over the wifi network being carrier merged.)
+ assertThat(latest).isFalse()
+ }
+
+ /** Regression test for b/278618530. */
+ @Test
+ fun hasCarrierMergedConnection_defaultCellular_wifiIsCarrierMerged_airplaneMode_isTrue() =
+ runTest {
+ val latest by underTest.hasCarrierMergedConnection.collectLastValue()
+
+ // WHEN the default callback is TRANSPORT_CELLULAR and not carrier merged
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_CELLULAR) } doReturn true
+ on { transportInfo } doReturn null
+ }
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ // BUT the wifi repo has gotten updates that it *is* carrier merged
+ setWifiState(isCarrierMerged = true)
+ // AND we're in airplane mode
+ airplaneModeRepository.setIsAirplaneMode(true)
+
+ // THEN hasCarrierMergedConnection is true.
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun defaultConnectionIsValidated_startsAsFalse() = runTest {
+ assertThat(underTest.defaultConnectionIsValidated.collectLastValue().value).isFalse()
+ }
+
+ @Test
+ fun defaultConnectionIsValidated_capsHaveValidated_isValidated() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn true
+ }
+
+ val latest by underTest.defaultConnectionIsValidated.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun defaultConnectionIsValidated_capsHaveNotValidated_isNotValidated() = runTest {
+ val caps =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn false
+ }
+
+ val latest by underTest.defaultConnectionIsValidated.collectLastValue()
+
+ getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun config_initiallyFromContext() = runTest {
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ assertTrue(latest!!.areEqual(configFromContext))
+ assertTrue(latest!!.showAtLeast3G)
+ }
+
+ @Test
+ fun config_subIdChangeEvent_updated() = runTest {
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ assertThat(latest!!.showAtLeast3G).isFalse()
+
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ // WHEN the change event is fired
+ val intent =
+ Intent(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ .putExtra(PhoneConstants.SUBSCRIPTION_KEY, SUB_1_ID)
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(applicationContext, intent)
+
+ // THEN the config is updated
+ assertThat(latest?.areEqual(configFromContext)).isEqualTo(true)
+ assertThat(latest?.showAtLeast3G).isEqualTo(true)
+ }
+
+ @Test
+ fun config_carrierConfigChangeEvent_updated() = runTest {
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ assertThat(latest!!.showAtLeast3G).isFalse()
+
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ // WHEN the change event is fired
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(
+ applicationContext,
+ Intent(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED),
+ )
+
+ // THEN the config is updated
+ assertThat(latest?.areEqual(configFromContext)).isEqualTo(true)
+ assertThat(latest?.showAtLeast3G).isEqualTo(true)
+ }
+
+ @Test
+ fun carrierConfig_initialValueIsFetched() = runTest {
+ underTest
+ testScope.runCurrent()
+
+ // Value starts out false
+ assertThat(underTest.defaultDataSubRatConfig.sample().showAtLeast3G).isFalse()
+
+ overrideResource(R.bool.config_showMin3G, true)
+ val configFromContext = MobileMappings.Config.readConfig(applicationContext)
+ assertThat(configFromContext.showAtLeast3G).isTrue()
+
+ assertThat(broadcastDispatcher.numReceiversRegistered).isAtLeast(1)
+
+ // WHEN the change event is fired
+ broadcastDispatcher.sendIntentToMatchingReceiversOnly(
+ applicationContext,
+ Intent(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED),
+ )
+ testScope.runCurrent()
+
+ // WHEN collection starts AFTER the broadcast is sent out
+ val latest by underTest.defaultDataSubRatConfig.collectLastValue()
+
+ // THEN the config has the updated value
+ assertWithMessage("showAtLeast3G is false").that(latest!!.showAtLeast3G).isTrue()
+ assertWithMessage("not equal").that(latest!!.areEqual(configFromContext)).isTrue()
+ }
+
+ @Test
+ fun activeDataChange_inSameGroup_emitsUnit() = runTest {
+ var eventCount = 0
+ underTest
+ testScope.backgroundScope.launch {
+ kairos.activateSpec { underTest.activeSubChangedInGroupEvent.observe { eventCount++ } }
+ }
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_3_ID_GROUPED)
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_4_ID_GROUPED)
+ testScope.runCurrent()
+
+ assertThat(eventCount).isEqualTo(1)
+ }
+
+ @Test
+ fun activeDataChange_notInSameGroup_doesNotEmit() = runTest {
+ var eventCount = 0
+ underTest
+ testScope.backgroundScope.launch {
+ kairos.activateSpec { underTest.activeSubChangedInGroupEvent.observe { eventCount++ } }
+ }
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_3_ID_GROUPED)
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<ActiveDataSubscriptionIdListener>(telephonyManager)
+ .onActiveDataSubscriptionIdChanged(SUB_1_ID)
+ testScope.runCurrent()
+
+ assertThat(eventCount).isEqualTo(0)
+ }
+
+ @Test
+ fun anySimSecure_propagatesStateFromKeyguardUpdateMonitor() = runTest {
+ val latest by underTest.isAnySimSecure.collectLastValue()
+ assertThat(latest).isFalse()
+
+ val updateMonitorCallback = argumentCaptor<KeyguardUpdateMonitorCallback>()
+ verify(keyguardUpdateMonitor).registerCallback(updateMonitorCallback.capture())
+
+ keyguardUpdateMonitor.stub { on { isSimPinSecure } doReturn true }
+ updateMonitorCallback.lastValue.onSimStateChanged(0, 0, 0)
+
+ assertThat(latest).isTrue()
+
+ keyguardUpdateMonitor.stub { on { isSimPinSecure } doReturn false }
+ updateMonitorCallback.lastValue.onSimStateChanged(0, 0, 0)
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun getIsAnySimSecure_delegatesCallToKeyguardUpdateMonitor() = runTest {
+ val anySimSecure by underTest.isAnySimSecure.collectLastValue()
+
+ assertThat(anySimSecure).isFalse()
+
+ keyguardUpdateMonitor.stub { on { isSimPinSecure } doReturn true }
+ argumentCaptor<KeyguardUpdateMonitorCallback>()
+ .apply { verify(keyguardUpdateMonitor).registerCallback(capture()) }
+ .lastValue
+ .onSimStateChanged(0, 0, 0)
+
+ assertThat(anySimSecure).isTrue()
+ }
+
+ @Test
+ fun noSubscriptionsInEcmMode_notInEcmMode() = runTest {
+ val latest by underTest.isInEcmMode.collectLastValue()
+ testScope.runCurrent()
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun someSubscriptionsInEcmMode_inEcmMode() = runTest {
+ val latest by underTest.isInEcmMode.collectLastValue()
+ testScope.runCurrent()
+
+ getTelephonyCallbackForType<EmergencyCallbackModeListener>(telephonyManager)
+ .onCallbackModeStarted(0, mock(), 0)
+
+ assertThat(latest).isTrue()
+ }
+
+ private fun KairosTestScope.getDefaultNetworkCallback(): ConnectivityManager.NetworkCallback {
+ testScope.runCurrent()
+ val callbackCaptor = argumentCaptor<ConnectivityManager.NetworkCallback>()
+ verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture())
+ return callbackCaptor.lastValue
+ }
+
+ private fun KairosTestScope.setWifiState(isCarrierMerged: Boolean) {
+ if (isCarrierMerged) {
+ val mergedEntry =
+ mock<MergedCarrierEntry> {
+ on { isPrimaryNetwork } doReturn true
+ on { isDefaultNetwork } doReturn true
+ on { subscriptionId } doReturn SUB_CM_ID
+ }
+ wifiPickerTracker.stub {
+ on { mergedCarrierEntry } doReturn mergedEntry
+ on { connectedWifiEntry } doReturn null
+ }
+ } else {
+ val wifiEntry =
+ mock<WifiEntry> {
+ on { isPrimaryNetwork } doReturn true
+ on { isDefaultNetwork } doReturn true
+ }
+ wifiPickerTracker.stub {
+ on { connectedWifiEntry } doReturn wifiEntry
+ on { mergedCarrierEntry } doReturn null
+ }
+ }
+ wifiPickerTrackerCallback.allValues.forEach { it.onWifiEntriesChanged() }
+ }
+
+ private fun KairosTestScope.getSubscriptionCallback(): OnSubscriptionsChangedListener {
+ testScope.runCurrent()
+ return argumentCaptor<OnSubscriptionsChangedListener>()
+ .apply {
+ verify(subscriptionManager).addOnSubscriptionsChangedListener(any(), capture())
+ }
+ .lastValue
+ }
+
+ companion object {
+ // Subscription 1
+ private const val SUB_1_ID = 1
+ private const val SUB_1_NAME = "Carrier $SUB_1_ID"
+ private val GROUP_1 = ParcelUuid(UUID.randomUUID())
+ private val SUB_1 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_1_ID
+ on { groupUuid } doReturn GROUP_1
+ on { carrierName } doReturn SUB_1_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ groupUuid = GROUP_1,
+ carrierName = SUB_1_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // Subscription 2
+ private const val SUB_2_ID = 2
+ private const val SUB_2_NAME = "Carrier $SUB_2_ID"
+ private val GROUP_2 = ParcelUuid(UUID.randomUUID())
+ private val SUB_2 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_2_ID
+ on { groupUuid } doReturn GROUP_2
+ on { carrierName } doReturn SUB_2_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ groupUuid = GROUP_2,
+ carrierName = SUB_2_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // Subs 3 and 4 are considered to be in the same group ------------------------------------
+ private val GROUP_ID_3_4 = ParcelUuid(UUID.randomUUID())
+
+ // Subscription 3
+ private const val SUB_3_ID_GROUPED = 3
+ private val SUB_3 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_3_ID_GROUPED
+ on { groupUuid } doReturn GROUP_ID_3_4
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ // Subscription 4
+ private const val SUB_4_ID_GROUPED = 4
+ private val SUB_4 =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_4_ID_GROUPED
+ on { groupUuid } doReturn GROUP_ID_3_4
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+
+ // Subs 3 and 4 are considered to be in the same group ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+ private const val NET_ID = 123
+ private val NETWORK = mock<Network> { on { getNetId() } doReturn NET_ID }
+
+ // Carrier merged subscription
+ private const val SUB_CM_ID = 5
+ private const val SUB_CM_NAME = "Carrier $SUB_CM_ID"
+ private val SUB_CM =
+ mock<SubscriptionInfo> {
+ on { subscriptionId } doReturn SUB_CM_ID
+ on { carrierName } doReturn SUB_CM_NAME
+ on { profileClass } doReturn PROFILE_CLASS_UNSET
+ }
+ private val MODEL_CM =
+ SubscriptionModel(
+ subscriptionId = SUB_CM_ID,
+ carrierName = SUB_CM_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private val WIFI_INFO_CM =
+ mock<WifiInfo> {
+ on { isPrimary } doReturn true
+ on { isCarrierMerged } doReturn true
+ on { subscriptionId } doReturn SUB_CM_ID
+ }
+ private val WIFI_NETWORK_CAPS_CM =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn WIFI_INFO_CM
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn true
+ }
+
+ private val WIFI_INFO_ACTIVE =
+ mock<WifiInfo> {
+ on { isPrimary } doReturn true
+ on { isCarrierMerged } doReturn false
+ }
+
+ private val WIFI_NETWORK_CAPS_ACTIVE =
+ Mockito.mock(NetworkCapabilities::class.java).stub {
+ on { hasTransport(TRANSPORT_WIFI) } doReturn true
+ on { transportInfo } doReturn WIFI_INFO_ACTIVE
+ on { hasCapability(NET_CAPABILITY_VALIDATED) } doReturn true
+ }
+
+ /**
+ * To properly mimic telephony manager, create a service state, and then turn it into an
+ * intent
+ */
+ private fun serviceStateIntent(subId: Int): Intent {
+ return Intent(Intent.ACTION_SERVICE_STATE).apply {
+ putExtra(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, subId)
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt
new file mode 100644
index 000000000000..c89dc5722c7a
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairosTest.kt
@@ -0,0 +1,868 @@
+/*
+ * Copyright (C) 2022 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.domain.interactor
+
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import android.telephony.CellSignalStrength
+import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.mobile.MobileIconCarrierIdOverrides
+import com.android.settingslib.mobile.MobileIconCarrierIdOverridesImpl
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.CarrierMergedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.FIVE_G_OVERRIDE
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.FOUR_G
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.THREE_G
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy
+import com.android.systemui.testKosmos
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyString
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileIconInteractorKairosTest : SysuiTestCase() {
+ private val kosmos = testKosmos()
+
+ private lateinit var underTest: MobileIconInteractorKairos
+ private val mobileMappingsProxy = FakeMobileMappingsProxy()
+ private val mobileIconsInteractor = FakeMobileIconsInteractor(mobileMappingsProxy, mock())
+
+ private val connectionRepository =
+ FakeMobileConnectionRepository(
+ SUB_1_ID,
+ logcatTableLogBuffer(kosmos, "MobileIconInteractorTest"),
+ )
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ @Before
+ fun setUp() {
+ underTest = createInteractor()
+
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled.value = true
+ connectionRepository.isInService.value = true
+ }
+
+ @Test
+ fun gsm_usesGsmLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = true
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(GSM_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun gsm_alwaysShowCdmaTrue_stillUsesGsmLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = true
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+ mobileIconsInteractor.alwaysUseCdmaLevel.value = true
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(GSM_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun notGsm_level_default_unknown() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = false
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN)
+ job.cancel()
+ }
+
+ @Test
+ fun notGsm_alwaysShowCdmaTrue_usesCdmaLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = false
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+ mobileIconsInteractor.alwaysUseCdmaLevel.value = true
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(CDMA_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun notGsm_alwaysShowCdmaFalse_usesPrimaryLevel() =
+ testScope.runTest {
+ connectionRepository.isGsm.value = false
+ connectionRepository.primaryLevel.value = GSM_LEVEL
+ connectionRepository.cdmaLevel.value = CDMA_LEVEL
+ mobileIconsInteractor.alwaysUseCdmaLevel.value = false
+
+ var latest: Int? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it.level }.launchIn(this)
+
+ assertThat(latest).isEqualTo(GSM_LEVEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun numberOfLevels_comesFromRepo_whenApplicable() =
+ testScope.runTest {
+ var latest: Int? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = (it as? SignalIconModel.Cellular)?.numberOfLevels }
+ .launchIn(this)
+
+ connectionRepository.numberOfLevels.value = 5
+ assertThat(latest).isEqualTo(5)
+
+ connectionRepository.numberOfLevels.value = 4
+ assertThat(latest).isEqualTo(4)
+
+ job.cancel()
+ }
+
+ @Test
+ fun inflateSignalStrength_arbitrarilyAddsOneToTheReportedLevel() =
+ testScope.runTest {
+ connectionRepository.inflateSignalStrength.value = false
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ connectionRepository.primaryLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.inflateSignalStrength.value = true
+ connectionRepository.primaryLevel.value = 4
+
+ // when INFLATE_SIGNAL_STRENGTH is true, we add 1 to the reported signal level
+ assertThat(latest!!.level).isEqualTo(5)
+ }
+
+ @Test
+ fun networkSlice_configOn_hasPrioritizedCaps_showsSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = true
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = true
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun networkSlice_configOn_noPrioritizedCaps_noSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = true
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun networkSlice_configOff_hasPrioritizedCaps_noSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = false
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = true
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun networkSlice_configOff_noPrioritizedCaps_noSlice() =
+ testScope.runTest {
+ connectionRepository.allowNetworkSliceIndicator.value = false
+ val latest by collectLastValue(underTest.showSliceAttribution)
+
+ connectionRepository.hasPrioritizedNetworkCapabilities.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun iconGroup_three_g() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(THREE_G))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest).isEqualTo(NetworkTypeIconModel.DefaultIcon(TelephonyIcons.THREE_G))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_updates_on_change() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(THREE_G))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(FOUR_G))
+
+ assertThat(latest).isEqualTo(NetworkTypeIconModel.DefaultIcon(TelephonyIcons.FOUR_G))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_5g_override_type() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(FIVE_G_OVERRIDE))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest).isEqualTo(NetworkTypeIconModel.DefaultIcon(TelephonyIcons.NR_5G))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_default_if_no_lookup() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(NETWORK_TYPE_UNKNOWN))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest)
+ .isEqualTo(NetworkTypeIconModel.DefaultIcon(FakeMobileIconsInteractor.DEFAULT_ICON))
+
+ job.cancel()
+ }
+
+ @Test
+ fun iconGroup_carrierMerged_usesOverride() =
+ testScope.runTest {
+ connectionRepository.resolvedNetworkType.value = CarrierMergedNetworkType
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest)
+ .isEqualTo(
+ NetworkTypeIconModel.DefaultIcon(CarrierMergedNetworkType.iconGroupOverride)
+ )
+
+ job.cancel()
+ }
+
+ @Test
+ fun overrideIcon_usesCarrierIdOverride() =
+ testScope.runTest {
+ val overrides =
+ mock<MobileIconCarrierIdOverrides>().also {
+ whenever(it.carrierIdEntryExists(anyInt())).thenReturn(true)
+ whenever(it.getOverrideFor(anyInt(), anyString(), any())).thenReturn(1234)
+ }
+
+ underTest = createInteractor(overrides)
+
+ connectionRepository.resolvedNetworkType.value =
+ DefaultNetworkType(mobileMappingsProxy.toIconKey(THREE_G))
+
+ var latest: NetworkTypeIconModel? = null
+ val job = underTest.networkTypeIconGroup.onEach { latest = it }.launchIn(this)
+
+ assertThat(latest)
+ .isEqualTo(NetworkTypeIconModel.OverriddenIcon(TelephonyIcons.THREE_G, 1234))
+
+ job.cancel()
+ }
+
+ @Test
+ fun alwaysShowDataRatIcon_matchesParent() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.alwaysShowDataRatIcon.onEach { latest = it }.launchIn(this)
+
+ mobileIconsInteractor.alwaysShowDataRatIcon.value = true
+ assertThat(latest).isTrue()
+
+ mobileIconsInteractor.alwaysShowDataRatIcon.value = false
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun dataState_connected() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isDataConnected.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.dataConnectionState.value = DataConnectionState.Connected
+
+ assertThat(latest).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun dataState_notConnected() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isDataConnected.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.dataConnectionState.value = DataConnectionState.Disconnected
+
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun isInService_usesRepositoryValue() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isInService.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.isInService.value = true
+
+ assertThat(latest).isTrue()
+
+ connectionRepository.isInService.value = false
+
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun roaming_isGsm_usesConnectionModel() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isRoaming.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = true
+ connectionRepository.isRoaming.value = false
+
+ assertThat(latest).isFalse()
+
+ connectionRepository.isRoaming.value = true
+
+ assertThat(latest).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun roaming_isCdma_usesCdmaRoamingBit() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isRoaming.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.cdmaRoaming.value = false
+ connectionRepository.isGsm.value = false
+ connectionRepository.isRoaming.value = true
+
+ assertThat(latest).isFalse()
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = false
+ connectionRepository.isRoaming.value = false
+
+ assertThat(latest).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun roaming_falseWhileCarrierNetworkChangeActive() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isRoaming.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = false
+ connectionRepository.isRoaming.value = true
+ connectionRepository.carrierNetworkChangeActive.value = true
+
+ assertThat(latest).isFalse()
+
+ connectionRepository.cdmaRoaming.value = true
+ connectionRepository.isGsm.value = true
+
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun networkName_usesOperatorAlphaShortWhenNonNullAndRepoIsDefault() =
+ testScope.runTest {
+ var latest: NetworkNameModel? = null
+ val job = underTest.networkName.onEach { latest = it }.launchIn(this)
+
+ val testOperatorName = "operatorAlphaShort"
+
+ // Default network name, operator name is non-null, uses the operator name
+ connectionRepository.networkName.value = DEFAULT_NAME_MODEL
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived(testOperatorName))
+
+ // Default network name, operator name is null, uses the default
+ connectionRepository.operatorAlphaShort.value = null
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+ // Derived network name, operator name non-null, uses the derived name
+ connectionRepository.networkName.value = DERIVED_NAME_MODEL
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(DERIVED_NAME_MODEL)
+
+ job.cancel()
+ }
+
+ @Test
+ fun networkNameForSubId_usesOperatorAlphaShortWhenNonNullAndRepoIsDefault() =
+ testScope.runTest {
+ var latest: String? = null
+ val job = underTest.carrierName.onEach { latest = it }.launchIn(this)
+
+ val testOperatorName = "operatorAlphaShort"
+
+ // Default network name, operator name is non-null, uses the operator name
+ connectionRepository.carrierName.value = DEFAULT_NAME_MODEL
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(testOperatorName)
+
+ // Default network name, operator name is null, uses the default
+ connectionRepository.operatorAlphaShort.value = null
+
+ assertThat(latest).isEqualTo(DEFAULT_NAME)
+
+ // Derived network name, operator name non-null, uses the derived name
+ connectionRepository.carrierName.value =
+ NetworkNameModel.SubscriptionDerived(DERIVED_NAME)
+ connectionRepository.operatorAlphaShort.value = testOperatorName
+
+ assertThat(latest).isEqualTo(DERIVED_NAME)
+
+ job.cancel()
+ }
+
+ @Test
+ fun isSingleCarrier_matchesParent() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+ mobileIconsInteractor.isSingleCarrier.value = true
+ assertThat(latest).isTrue()
+
+ mobileIconsInteractor.isSingleCarrier.value = false
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun isForceHidden_matchesParent() =
+ testScope.runTest {
+ var latest: Boolean? = null
+ val job = underTest.isForceHidden.onEach { latest = it }.launchIn(this)
+
+ mobileIconsInteractor.isForceHidden.value = true
+ assertThat(latest).isTrue()
+
+ mobileIconsInteractor.isForceHidden.value = false
+ assertThat(latest).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun isAllowedDuringAirplaneMode_matchesRepo() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.isAllowedDuringAirplaneMode)
+
+ connectionRepository.isAllowedDuringAirplaneMode.value = true
+ assertThat(latest).isTrue()
+
+ connectionRepository.isAllowedDuringAirplaneMode.value = false
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun cellBasedIconId_correctLevel_notCutout() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+ connectionRepository.primaryLevel.value = 1
+ connectionRepository.setDataEnabled(false)
+ connectionRepository.isNonTerrestrial.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest?.level).isEqualTo(1)
+ assertThat(latest?.showExclamationMark).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun icon_usesLevelFromInteractor() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+
+ var latest: SignalIconModel? = null
+ val job = underTest.signalLevelIcon.onEach { latest = it }.launchIn(this)
+
+ connectionRepository.primaryLevel.value = 3
+ assertThat(latest!!.level).isEqualTo(3)
+
+ connectionRepository.primaryLevel.value = 1
+ assertThat(latest!!.level).isEqualTo(1)
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_usesNumberOfLevelsFromInteractor() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ connectionRepository.numberOfLevels.value = 5
+ assertThat(latest!!.numberOfLevels).isEqualTo(5)
+
+ connectionRepository.numberOfLevels.value = 2
+ assertThat(latest!!.numberOfLevels).isEqualTo(2)
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_defaultDataDisabled_showExclamationTrue() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest!!.showExclamationMark).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_defaultConnectionFailed_showExclamationTrue() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ mobileIconsInteractor.isDefaultConnectionFailed.value = true
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest!!.showExclamationMark).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_enabledAndNotFailed_showExclamationFalse() =
+ testScope.runTest {
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled.value = true
+ mobileIconsInteractor.isDefaultConnectionFailed.value = false
+
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ assertThat(latest!!.showExclamationMark).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_usesEmptyState_whenNotInService() =
+ testScope.runTest {
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular }
+ .launchIn(this)
+
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = false
+
+ assertThat(latest?.level).isEqualTo(0)
+ assertThat(latest?.showExclamationMark).isTrue()
+
+ // Changing the level doesn't overwrite the disabled state
+ connectionRepository.primaryLevel.value = 2
+ assertThat(latest?.level).isEqualTo(0)
+ assertThat(latest?.showExclamationMark).isTrue()
+
+ // Once back in service, the regular icon appears
+ connectionRepository.isInService.value = true
+ assertThat(latest?.level).isEqualTo(2)
+ assertThat(latest?.showExclamationMark).isFalse()
+
+ job.cancel()
+ }
+
+ @Test
+ fun cellBasedIcon_usesCarrierNetworkState_whenInCarrierNetworkChangeMode() =
+ testScope.runTest {
+ var latest: SignalIconModel.Cellular? = null
+ val job =
+ underTest.signalLevelIcon
+ .onEach { latest = it as? SignalIconModel.Cellular? }
+ .launchIn(this)
+
+ connectionRepository.isNonTerrestrial.value = false
+ connectionRepository.isInService.value = true
+ connectionRepository.carrierNetworkChangeActive.value = true
+ connectionRepository.primaryLevel.value = 1
+ connectionRepository.cdmaLevel.value = 1
+
+ assertThat(latest!!.level).isEqualTo(1)
+ assertThat(latest!!.carrierNetworkChange).isTrue()
+
+ // SignalIconModel respects the current level
+ connectionRepository.primaryLevel.value = 2
+
+ assertThat(latest!!.level).isEqualTo(2)
+ assertThat(latest!!.carrierNetworkChange).isTrue()
+
+ job.cancel()
+ }
+
+ @Test
+ fun satBasedIcon_isUsedWhenNonTerrestrial() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // Start off using cellular
+ assertThat(latest).isInstanceOf(SignalIconModel.Cellular::class.java)
+
+ connectionRepository.isNonTerrestrial.value = true
+
+ assertThat(latest).isInstanceOf(SignalIconModel.Satellite::class.java)
+ }
+
+ @DisableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ // See b/346904529 for more context
+ fun satBasedIcon_doesNotInflateSignalStrength_flagOff() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+ // GIVEN this carrier has set INFLATE_SIGNAL_STRENGTH
+ connectionRepository.inflateSignalStrength.value = true
+
+ connectionRepository.primaryLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.inflateSignalStrength.value = true
+ connectionRepository.primaryLevel.value = 4
+
+ // Icon level is unaffected
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ @EnableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ // See b/346904529 for more context
+ fun satBasedIcon_doesNotInflateSignalStrength_flagOn() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+ // GIVEN this carrier has set INFLATE_SIGNAL_STRENGTH
+ connectionRepository.inflateSignalStrength.value = true
+
+ connectionRepository.satelliteLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.inflateSignalStrength.value = true
+ connectionRepository.primaryLevel.value = 4
+
+ // Icon level is unaffected
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ @DisableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ fun satBasedIcon_usesPrimaryLevel_flagOff() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+
+ // GIVEN primary level is set
+ connectionRepository.primaryLevel.value = 4
+ connectionRepository.satelliteLevel.value = 0
+
+ // THEN icon uses the primary level because the flag is off
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ @EnableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ fun satBasedIcon_usesSatelliteLevel_flagOn() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+
+ // GIVEN satellite level is set
+ connectionRepository.satelliteLevel.value = 4
+ connectionRepository.primaryLevel.value = 0
+
+ // THEN icon uses the satellite level because the flag is on
+ assertThat(latest!!.level).isEqualTo(4)
+ }
+
+ /**
+ * Context (b/377518113), this test will not be needed after FLAG_CARRIER_ROAMING_NB_IOT_NTN is
+ * rolled out. The new API should report 0 automatically if not in service.
+ */
+ @DisableFlags(com.android.internal.telephony.flags.Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+ @Test
+ fun satBasedIcon_reportsLevelZeroWhenOutOfService() =
+ testScope.runTest {
+ val latest by collectLastValue(underTest.signalLevelIcon)
+
+ // GIVEN a satellite connection
+ connectionRepository.isNonTerrestrial.value = true
+ // GIVEN this carrier has set INFLATE_SIGNAL_STRENGTH
+ connectionRepository.inflateSignalStrength.value = true
+
+ connectionRepository.primaryLevel.value = 4
+ assertThat(latest!!.level).isEqualTo(4)
+
+ connectionRepository.isInService.value = false
+ connectionRepository.primaryLevel.value = 4
+
+ // THEN level reports 0, by policy
+ assertThat(latest!!.level).isEqualTo(0)
+ }
+
+ private fun createInteractor(
+ overrides: MobileIconCarrierIdOverrides = MobileIconCarrierIdOverridesImpl()
+ ) =
+ MobileIconInteractorKairosImpl(
+ testScope.backgroundScope,
+ mobileIconsInteractor.activeDataConnectionHasDataEnabled,
+ mobileIconsInteractor.alwaysShowDataRatIcon,
+ mobileIconsInteractor.alwaysUseCdmaLevel,
+ mobileIconsInteractor.isSingleCarrier,
+ mobileIconsInteractor.mobileIsDefault,
+ mobileIconsInteractor.defaultMobileIconMapping,
+ mobileIconsInteractor.defaultMobileIconGroup,
+ mobileIconsInteractor.isDefaultConnectionFailed,
+ mobileIconsInteractor.isForceHidden,
+ connectionRepository,
+ context,
+ overrides,
+ )
+
+ companion object {
+ private const val GSM_LEVEL = 1
+ private const val CDMA_LEVEL = 2
+
+ private const val SUB_1_ID = 1
+
+ private const val DEFAULT_NAME = "test default name"
+ private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+ private const val DERIVED_NAME = "test derived name"
+ private val DERIVED_NAME_MODEL = NetworkNameModel.IntentDerived(DERIVED_NAME)
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt
new file mode 100644
index 000000000000..a9360d139a3d
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairosTest.kt
@@ -0,0 +1,1046 @@
+/*
+ * Copyright (C) 2022 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.domain.interactor
+
+import android.os.ParcelUuid
+import android.platform.test.annotations.EnableFlags
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.SubscriptionManager.PROFILE_CLASS_PROVISIONING
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.flags.Flags
+import com.android.systemui.flags.fake
+import com.android.systemui.flags.featureFlagsClassic
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.collectLastValue
+import com.android.systemui.kosmos.runCurrent
+import com.android.systemui.kosmos.runTest
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.statusbar.core.NewStatusBarIcons
+import com.android.systemui.statusbar.core.StatusBarRootModernization
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fake
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.fakeMobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.mobileConnectionsRepositoryLogbufferName
+import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlot
+import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
+import com.android.systemui.statusbar.pipeline.shared.data.repository.fake
+import com.android.systemui.statusbar.policy.data.repository.FakeUserSetupRepository
+import com.android.systemui.testKosmos
+import com.android.systemui.util.CarrierConfigTracker
+import com.google.common.truth.Truth.assertThat
+import java.util.UUID
+import kotlinx.coroutines.test.advanceTimeBy
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MobileIconsInteractorKairosTest : SysuiTestCase() {
+ private val kosmos by lazy {
+ testKosmos().apply {
+ mobileConnectionsRepositoryLogbufferName = "MobileIconsInteractorTest"
+ mobileConnectionsRepository.fake.run {
+ setMobileConnectionRepositoryMap(
+ mapOf(
+ SUB_1_ID to FakeMobileConnectionRepository(SUB_1_ID, mock()),
+ SUB_2_ID to FakeMobileConnectionRepository(SUB_2_ID, mock()),
+ SUB_3_ID to FakeMobileConnectionRepository(SUB_3_ID, mock()),
+ SUB_4_ID to FakeMobileConnectionRepository(SUB_4_ID, mock()),
+ )
+ )
+ setActiveMobileDataSubscriptionId(SUB_1_ID)
+ }
+ featureFlagsClassic.fake.set(Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS, true)
+ }
+ }
+
+ // shortcut rename
+ private val Kosmos.connectionsRepository by Fixture { mobileConnectionsRepository.fake }
+
+ private val Kosmos.carrierConfigTracker by Fixture { mock<CarrierConfigTracker>() }
+
+ private val Kosmos.underTest by Fixture {
+ MobileIconsInteractorKairosImpl(
+ mobileConnectionsRepository,
+ carrierConfigTracker,
+ tableLogger = mock(),
+ connectivityRepository,
+ FakeUserSetupRepository(),
+ testScope.backgroundScope,
+ context,
+ featureFlagsClassic,
+ )
+ }
+
+ @Test
+ fun filteredSubscriptions_default() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf<SubscriptionModel>())
+ }
+
+ // Based on the logic from the old pipeline, we'll never filter subs when there are more than 2
+ @Test
+ fun filteredSubscriptions_moreThanTwo_doesNotFilter() =
+ kosmos.runTest {
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_3_OPP, SUB_4_OPP))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_4_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(SUB_1, SUB_3_OPP, SUB_4_OPP))
+ }
+
+ @Test
+ fun filteredSubscriptions_nonOpportunistic_updatesWithMultipleSubs() =
+ kosmos.runTest {
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(SUB_1, SUB_2))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_differentGroups_doesNotFilter() =
+ kosmos.runTest {
+ connectionsRepository.setSubscriptions(listOf(SUB_3_OPP, SUB_4_OPP))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(SUB_3_OPP, SUB_4_OPP))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_nonGrouped_doesNotFilter() =
+ kosmos.runTest {
+ val (sub1, sub2) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_2_ID),
+ opportunistic = Pair(true, true),
+ grouped = false,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_1_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1, sub2))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_grouped_configFalse_showsActive_3() =
+ kosmos.runTest {
+ val (sub3, sub4) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_3_ID, SUB_4_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub3, sub4))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the active one when the config is false
+ assertThat(latest).isEqualTo(listOf(sub3))
+ }
+
+ @Test
+ fun filteredSubscriptions_opportunistic_grouped_configFalse_showsActive_4() =
+ kosmos.runTest {
+ val (sub3, sub4) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_3_ID, SUB_4_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub3, sub4))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_4_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the active one when the config is false
+ assertThat(latest).isEqualTo(listOf(sub4))
+ }
+
+ @Test
+ fun filteredSubscriptions_oneOpportunistic_grouped_configTrue_showsPrimary_active_1() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(false, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_1_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(true)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the primary (non-opportunistic) if the config is
+ // true
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_oneOpportunistic_grouped_configTrue_showsPrimary_nonActive_1() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(false, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(true)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // Filtered subscriptions should show the primary (non-opportunistic) if the config is
+ // true
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_vcnSubId_agreesWithActiveSubId_usesActiveAkaVcnSub() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ kosmos.connectivityRepository.fake.vcnSubId.value = SUB_3_ID
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub3))
+ }
+
+ @Test
+ fun filteredSubscriptions_vcnSubId_disagreesWithActiveSubId_usesVcnSub() =
+ kosmos.runTest {
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+ connectionsRepository.setSubscriptions(listOf(sub1, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+ kosmos.connectivityRepository.fake.vcnSubId.value = SUB_1_ID
+ whenever(carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault)
+ .thenReturn(false)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_doesNotFilterProvisioningWhenFlagIsFalse() =
+ kosmos.runTest {
+ // GIVEN the flag is false
+ featureFlagsClassic.fake.set(Flags.FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS, false)
+
+ // GIVEN 1 sub that is in PROFILE_CLASS_PROVISIONING
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ isOpportunistic = false,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(sub1))
+
+ // WHEN filtering is applied
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // THEN the provisioning sub is still present (unfiltered)
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_filtersOutProvisioningSubs() =
+ kosmos.runTest {
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ isOpportunistic = false,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ isOpportunistic = false,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ /** Note: I'm not sure if this will ever be the case, but we can test it at least */
+ @Test
+ fun filteredSubscriptions_filtersOutProvisioningSubsBeforeOpportunistic() =
+ kosmos.runTest {
+ // This is a contrived test case, where the active subId is the one that would
+ // also be filtered by opportunistic filtering.
+
+ // GIVEN grouped, opportunistic subscriptions
+ val groupUuid = ParcelUuid(UUID.randomUUID())
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = 1,
+ isOpportunistic = true,
+ groupUuid = groupUuid,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = 2,
+ isOpportunistic = true,
+ groupUuid = groupUuid,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // GIVEN active subId is 1
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2))
+ connectionsRepository.setActiveMobileDataSubscriptionId(1)
+
+ // THEN filtering of provisioning subs takes place first, and we result in sub2
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub2))
+ }
+
+ @Test
+ fun filteredSubscriptions_groupedPairAndNonProvisioned_groupedFilteringStillHappens() =
+ kosmos.runTest {
+ // Grouped filtering only happens when the list of subs is length 2. In this case
+ // we'll show that filtering of provisioning subs happens before, and thus grouped
+ // filtering happens even though the unfiltered list is length 3
+ val (sub1, sub3) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_1_ID, SUB_3_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = 2,
+ isOpportunistic = true,
+ groupUuid = null,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_PROVISIONING,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(sub1, sub2, sub3))
+ connectionsRepository.setActiveMobileDataSubscriptionId(1)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(sub1))
+ }
+
+ @Test
+ fun filteredSubscriptions_subNotExclusivelyNonTerrestrial_hasSub() =
+ kosmos.runTest {
+ val notExclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = false,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(notExclusivelyNonTerrestrialSub))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(notExclusivelyNonTerrestrialSub))
+ }
+
+ @Test
+ fun filteredSubscriptions_subExclusivelyNonTerrestrial_doesNotHaveSub() =
+ kosmos.runTest {
+ val exclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = true,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ connectionsRepository.setSubscriptions(listOf(exclusivelyNonTerrestrialSub))
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEmpty()
+ }
+
+ @Test
+ fun filteredSubscription_mixOfExclusivelyNonTerrestrialAndOther_hasOtherSubsOnly() =
+ kosmos.runTest {
+ val exclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = true,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ val otherSub1 =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = false,
+ subscriptionId = 1,
+ carrierName = "Carrier 1",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ val otherSub2 =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = false,
+ subscriptionId = 2,
+ carrierName = "Carrier 2",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ connectionsRepository.setSubscriptions(
+ listOf(otherSub1, exclusivelyNonTerrestrialSub, otherSub2)
+ )
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ assertThat(latest).isEqualTo(listOf(otherSub1, otherSub2))
+ }
+
+ @Test
+ fun filteredSubscriptions_exclusivelyNonTerrestrialSub_andOpportunistic_bothFiltersHappen() =
+ kosmos.runTest {
+ // Exclusively non-terrestrial sub
+ val exclusivelyNonTerrestrialSub =
+ SubscriptionModel(
+ isExclusivelyNonTerrestrial = true,
+ subscriptionId = 5,
+ carrierName = "Carrier 5",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ // Opportunistic subs
+ val (sub3, sub4) =
+ createSubscriptionPair(
+ subscriptionIds = Pair(SUB_3_ID, SUB_4_ID),
+ opportunistic = Pair(true, true),
+ grouped = true,
+ )
+
+ // WHEN both an exclusively non-terrestrial sub and opportunistic sub pair is included
+ connectionsRepository.setSubscriptions(listOf(sub3, sub4, exclusivelyNonTerrestrialSub))
+ connectionsRepository.setActiveMobileDataSubscriptionId(SUB_3_ID)
+
+ val latest by collectLastValue(underTest.filteredSubscriptions)
+
+ // THEN both the only-non-terrestrial sub and the non-active sub are filtered out,
+ // leaving only sub3.
+ assertThat(latest).isEqualTo(listOf(sub3))
+ }
+
+ @Test
+ fun activeDataConnection_turnedOn() =
+ kosmos.runTest {
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .dataEnabled
+ .value = true
+
+ val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun activeDataConnection_turnedOff() =
+ kosmos.runTest {
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .dataEnabled
+ .value = true
+
+ val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled)
+
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .dataEnabled
+ .value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun activeDataConnection_invalidSubId() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.activeDataConnectionHasDataEnabled)
+
+ connectionsRepository.setActiveMobileDataSubscriptionId(INVALID_SUBSCRIPTION_ID)
+
+ // An invalid active subId should tell us that data is off
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_default_validated_notFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_notDefault_notValidated_notFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = false
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_default_notValidated_failed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun failedConnection_carrierMergedDefault_notValidated_failed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.hasCarrierMergedConnection.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/275076959. */
+ @Test
+ fun failedConnection_dataSwitchInSameGroup_notFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // WHEN there's a data change in the same subscription group
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // THEN the default connection is *not* marked as failed because of forced validation
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun failedConnection_dataSwitchNotInSameGroup_isFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // WHEN the connection is invalidated without a activeSubChangedInGroupEvent
+ connectionsRepository.defaultConnectionIsValidated.value = false
+
+ // THEN the connection is immediately marked as failed
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun alwaysShowDataRatIcon_configHasTrue() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysShowDataRatIcon)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowDataRatIcon = true
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun alwaysShowDataRatIcon_configHasFalse() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysShowDataRatIcon)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowDataRatIcon = false
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun alwaysUseCdmaLevel_configHasTrue() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysUseCdmaLevel)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowCdmaRssi = true
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun alwaysUseCdmaLevel_configHasFalse() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.alwaysUseCdmaLevel)
+
+ val config = MobileMappings.Config()
+ config.alwaysShowCdmaRssi = false
+ connectionsRepository.defaultDataSubRatConfig.value = config
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isSingleCarrier_zeroSubscriptions_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(emptyList())
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isSingleCarrier_oneSubscription_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1))
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun isSingleCarrier_twoSubscriptions_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun isSingleCarrier_updates() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isSingleCarrier)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1))
+ assertThat(latest).isTrue()
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_mobileFalseAndCarrierMergedFalse_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = false
+ connectionsRepository.hasCarrierMergedConnection.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun mobileIsDefault_mobileTrueAndCarrierMergedFalse_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.hasCarrierMergedConnection.value = false
+
+ assertThat(latest).isTrue()
+ }
+
+ /** Regression test for b/272586234. */
+ @Test
+ fun mobileIsDefault_mobileFalseAndCarrierMergedTrue_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = false
+ connectionsRepository.hasCarrierMergedConnection.value = true
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun mobileIsDefault_updatesWhenRepoUpdates() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.mobileIsDefault)
+
+ connectionsRepository.mobileIsDefault.value = true
+ assertThat(latest).isTrue()
+
+ connectionsRepository.mobileIsDefault.value = false
+ assertThat(latest).isFalse()
+
+ connectionsRepository.hasCarrierMergedConnection.value = true
+ assertThat(latest).isTrue()
+ }
+
+ // The data switch tests are mostly testing the [forcingCellularValidation] flow, but that flow
+ // is private and can only be tested by looking at [isDefaultConnectionFailed].
+
+ @Test
+ fun dataSwitch_inSameGroup_validatedMatchesPreviousValue_expiresAfter2s() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // Trigger a data change in the same subscription group that's not yet validated
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // After 1s, the force validation bit is still present, so the connection is not marked
+ // as failed
+ testScope.advanceTimeBy(1000)
+ assertThat(latest).isFalse()
+
+ // After 2s, the force validation expires so the connection updates to failed
+ testScope.advanceTimeBy(1001)
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun dataSwitch_inSameGroup_notValidated_immediatelyMarkedAsFailed() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun dataSwitch_loseValidation_thenSwitchHappens_clearsForcedBit() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+
+ // GIVEN the network starts validated
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ // WHEN a data change happens in the same group
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ // WHEN the validation bit is lost
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // WHEN another data change happens in the same group
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ // THEN the forced validation bit is still used...
+ assertThat(latest).isFalse()
+
+ testScope.advanceTimeBy(1000)
+ assertThat(latest).isFalse()
+
+ // ... but expires after 2s
+ testScope.advanceTimeBy(1001)
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun dataSwitch_whileAlreadyForcingValidation_resetsClock() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDefaultConnectionFailed)
+ connectionsRepository.mobileIsDefault.value = true
+ connectionsRepository.defaultConnectionIsValidated.value = true
+ runCurrent()
+
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+
+ testScope.advanceTimeBy(1000)
+
+ // WHEN another change in same group event happens
+ connectionsRepository.activeSubChangedInGroupEvent.emit(Unit)
+ connectionsRepository.defaultConnectionIsValidated.value = false
+ runCurrent()
+
+ // THEN the forced validation remains for exactly 2 more seconds from now
+
+ // 1.500s from second event
+ testScope.advanceTimeBy(1500)
+ assertThat(latest).isFalse()
+
+ // 2.001s from the second event
+ testScope.advanceTimeBy(501)
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun isForceHidden_repoHasMobileHidden_true() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isForceHidden)
+
+ kosmos.connectivityRepository.fake.setForceHiddenIcons(setOf(ConnectivitySlot.MOBILE))
+
+ assertThat(latest).isTrue()
+ }
+
+ @Test
+ fun isForceHidden_repoDoesNotHaveMobileHidden_false() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isForceHidden)
+
+ kosmos.connectivityRepository.fake.setForceHiddenIcons(setOf(ConnectivitySlot.WIFI))
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun iconInteractor_cachedPerSubId() =
+ kosmos.runTest {
+ val interactor1 = underTest.getMobileConnectionInteractorForSubId(SUB_1_ID)
+ val interactor2 = underTest.getMobileConnectionInteractorForSubId(SUB_1_ID)
+
+ assertThat(interactor1).isNotNull()
+ assertThat(interactor1).isSameInstanceAs(interactor2)
+ }
+
+ @Test
+ fun deviceBasedEmergencyMode_emergencyCallsOnly_followsDeviceServiceStateFromRepo() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isDeviceInEmergencyCallsOnlyMode)
+
+ connectionsRepository.isDeviceEmergencyCallCapable.value = true
+
+ assertThat(latest).isTrue()
+
+ connectionsRepository.isDeviceEmergencyCallCapable.value = false
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ fun defaultDataSubId_tracksRepo() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.defaultDataSubId)
+
+ connectionsRepository.defaultDataSubId.value = 1
+
+ assertThat(latest).isEqualTo(1)
+
+ connectionsRepository.defaultDataSubId.value = 2
+
+ assertThat(latest).isEqualTo(2)
+ }
+
+ @Test
+ @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
+ fun isStackable_tracksNumberOfSubscriptions() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isStackable)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1))
+ assertThat(latest).isFalse()
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ assertThat(latest).isTrue()
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2, SUB_3_OPP))
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
+ fun isStackable_checksForTerrestrialConnections() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isStackable)
+
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ setNumberOfLevelsForSubId(SUB_1_ID, 5)
+ setNumberOfLevelsForSubId(SUB_2_ID, 5)
+ assertThat(latest).isTrue()
+
+ (fakeMobileConnectionsRepository.getRepoForSubId(SUB_1_ID)
+ as FakeMobileConnectionRepository)
+ .isNonTerrestrial
+ .value = true
+
+ assertThat(latest).isFalse()
+ }
+
+ @Test
+ @EnableFlags(NewStatusBarIcons.FLAG_NAME, StatusBarRootModernization.FLAG_NAME)
+ fun isStackable_checksForNumberOfBars() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.isStackable)
+
+ // Number of levels is the same for both
+ connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+ setNumberOfLevelsForSubId(SUB_1_ID, 5)
+ setNumberOfLevelsForSubId(SUB_2_ID, 5)
+
+ assertThat(latest).isTrue()
+
+ // Change the number of levels to be different than SUB_2
+ setNumberOfLevelsForSubId(SUB_1_ID, 6)
+
+ assertThat(latest).isFalse()
+ }
+
+ private fun setNumberOfLevelsForSubId(subId: Int, numberOfLevels: Int) {
+ with(kosmos) {
+ (fakeMobileConnectionsRepository.getRepoForSubId(subId)
+ as FakeMobileConnectionRepository)
+ .numberOfLevels
+ .value = numberOfLevels
+ }
+ }
+
+ /**
+ * Convenience method for creating a pair of subscriptions to test the filteredSubscriptions
+ * flow.
+ */
+ private fun createSubscriptionPair(
+ subscriptionIds: Pair<Int, Int>,
+ opportunistic: Pair<Boolean, Boolean> = Pair(false, false),
+ grouped: Boolean = false,
+ ): Pair<SubscriptionModel, SubscriptionModel> {
+ val groupUuid = if (grouped) ParcelUuid(UUID.randomUUID()) else null
+ val sub1 =
+ SubscriptionModel(
+ subscriptionId = subscriptionIds.first,
+ isOpportunistic = opportunistic.first,
+ groupUuid = groupUuid,
+ carrierName = "Carrier ${subscriptionIds.first}",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ val sub2 =
+ SubscriptionModel(
+ subscriptionId = subscriptionIds.second,
+ isOpportunistic = opportunistic.second,
+ groupUuid = groupUuid,
+ carrierName = "Carrier ${opportunistic.second}",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ return Pair(sub1, sub2)
+ }
+
+ companion object {
+
+ private const val SUB_1_ID = 1
+ private val SUB_1 =
+ SubscriptionModel(
+ subscriptionId = SUB_1_ID,
+ carrierName = "Carrier $SUB_1_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_2_ID = 2
+ private val SUB_2 =
+ SubscriptionModel(
+ subscriptionId = SUB_2_ID,
+ carrierName = "Carrier $SUB_2_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_3_ID = 3
+ private val SUB_3_OPP =
+ SubscriptionModel(
+ subscriptionId = SUB_3_ID,
+ isOpportunistic = true,
+ groupUuid = ParcelUuid(UUID.randomUUID()),
+ carrierName = "Carrier $SUB_3_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+
+ private const val SUB_4_ID = 4
+ private val SUB_4_OPP =
+ SubscriptionModel(
+ subscriptionId = SUB_4_ID,
+ isOpportunistic = true,
+ groupUuid = ParcelUuid(UUID.randomUUID()),
+ carrierName = "Carrier $SUB_4_ID",
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt b/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt
index 9fddbfb16d4d..a1546949835c 100644
--- a/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt
+++ b/packages/SystemUI/src/com/android/systemui/log/table/Diffable.kt
@@ -16,6 +16,11 @@
package com.android.systemui.log.table
+import com.android.systemui.kairos.BuildScope
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.changes
+import com.android.systemui.kairos.effect
import com.android.systemui.util.kotlin.pairwiseBy
import kotlinx.coroutines.flow.Flow
@@ -184,3 +189,94 @@ fun <T> Flow<List<T>>.logDiffsForTable(
newVal
}
}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logIntDiffsForTable")
+fun BuildScope.logDiffsForTable(
+ intState: State<Int?>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ intState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new, isInitial = isInitial)
+ isInitial = false
+ }
+}
+
+/**
+ * Each time the flow is updated with a new value, logs the differences between the previous value
+ * and the new value to the given [tableLogBuffer].
+ *
+ * The new value's [Diffable.logDiffs] method will be used to log the differences to the table.
+ *
+ * @param columnPrefix a prefix that will be applied to every column name that gets logged.
+ */
+@ExperimentalKairosApi
+fun <T : Diffable<T>> BuildScope.logDiffsForTable(
+ diffableState: State<T>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+) {
+ val initialValue = diffableState.sampleDeferred()
+ effect {
+ // Fully log the initial value to the table.
+ tableLogBuffer.logChange(columnPrefix, isInitial = true) { row ->
+ initialValue.value.logFull(row)
+ }
+ }
+ diffableState.changes.observe { newState ->
+ val prevState = diffableState.sample()
+ tableLogBuffer.logDiffs(columnPrefix, prevVal = prevState, newVal = newState)
+ }
+}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logBooleanDiffsForTable")
+fun BuildScope.logDiffsForTable(
+ booleanState: State<Boolean>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ booleanState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new, isInitial = isInitial)
+ isInitial = false
+ }
+}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logStringDiffsForTable")
+fun BuildScope.logDiffsForTable(
+ stringState: State<String?>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ stringState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new, isInitial = isInitial)
+ isInitial = false
+ }
+}
+
+/** See [logDiffsForTable(TableLogBuffer, String, T)]. */
+@ExperimentalKairosApi
+@JvmName("logListDiffsForTable")
+fun <T> BuildScope.logDiffsForTable(
+ listState: State<List<T>>,
+ tableLogBuffer: TableLogBuffer,
+ columnPrefix: String = "",
+ columnName: String,
+) {
+ var isInitial = true
+ listState.observe { new ->
+ tableLogBuffer.logChange(columnPrefix, columnName, new.toString(), isInitial = isInitial)
+ isInitial = false
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
index c71162a22d2f..29528502aa03 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
@@ -18,7 +18,9 @@ package com.android.systemui.statusbar.pipeline.dagger
import android.net.wifi.WifiManager
import com.android.systemui.CoreStartable
+import com.android.systemui.Flags
import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.kairos.ExperimentalKairosApi
import com.android.systemui.log.LogBuffer
import com.android.systemui.log.LogBufferFactory
import com.android.systemui.log.table.TableLogBuffer
@@ -35,9 +37,15 @@ import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierCon
import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierConfigRepository
import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierConfigRepositoryImpl
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryKairosAdapter
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileRepositorySwitcher
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileRepositorySwitcherKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoModeMobileConnectionDataSourceKairosImpl
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionRepositoryKairosFactoryImpl
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryKairosImpl
import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor
import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractorImpl
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractorKairosImpl
import com.android.systemui.statusbar.pipeline.mobile.ui.MobileUiAdapter
import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel
import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
@@ -74,9 +82,20 @@ import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import java.util.function.Supplier
import javax.inject.Named
+import javax.inject.Provider
import kotlinx.coroutines.flow.Flow
-@Module
+@OptIn(ExperimentalKairosApi::class)
+@Module(
+ includes =
+ [
+ DemoModeMobileConnectionDataSourceKairosImpl.Module::class,
+ MobileRepositorySwitcherKairos.Module::class,
+ MobileConnectionsRepositoryKairosImpl.Module::class,
+ MobileConnectionRepositoryKairosFactoryImpl.Module::class,
+ MobileConnectionsRepositoryKairosAdapter.Module::class,
+ ]
+)
abstract class StatusBarPipelineModule {
@Binds
abstract fun airplaneModeRepository(impl: AirplaneModeRepositoryImpl): AirplaneModeRepository
@@ -117,11 +136,6 @@ abstract class StatusBarPipelineModule {
@Binds abstract fun wifiInteractor(impl: WifiInteractorImpl): WifiInteractor
- @Binds
- abstract fun mobileConnectionsRepository(
- impl: MobileRepositorySwitcher
- ): MobileConnectionsRepository
-
@Binds abstract fun userSetupRepository(impl: UserSetupRepositoryImpl): UserSetupRepository
@Binds abstract fun mobileMappingsProxy(impl: MobileMappingsProxyImpl): MobileMappingsProxy
@@ -135,9 +149,6 @@ abstract class StatusBarPipelineModule {
): SubscriptionManagerProxy
@Binds
- abstract fun mobileIconsInteractor(impl: MobileIconsInteractorImpl): MobileIconsInteractor
-
- @Binds
@IntoMap
@ClassKey(MobileUiAdapter::class)
abstract fun bindFeature(impl: MobileUiAdapter): CoreStartable
@@ -158,6 +169,30 @@ abstract class StatusBarPipelineModule {
companion object {
@Provides
+ fun mobileIconsInteractor(
+ impl: Provider<MobileIconsInteractorImpl>,
+ kairosImpl: Provider<MobileIconsInteractorKairosImpl>,
+ ): MobileIconsInteractor {
+ return if (Flags.statusBarMobileIconKairos()) {
+ kairosImpl.get()
+ } else {
+ impl.get()
+ }
+ }
+
+ @Provides
+ fun mobileConnectionsRepository(
+ impl: Provider<MobileRepositorySwitcher>,
+ kairosImpl: Provider<MobileConnectionsRepositoryKairosAdapter>,
+ ): MobileConnectionsRepository {
+ return if (Flags.statusBarMobileIconKairos()) {
+ kairosImpl.get()
+ } else {
+ impl.get()
+ }
+ }
+
+ @Provides
@SysUISingleton
fun provideRealWifiRepository(
wifiManager: WifiManager?,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..2e796263afa9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryKairos.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.telephony.CellSignalStrength
+import android.telephony.TelephonyManager
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+
+/**
+ * Every mobile line of service can be identified via a [SubscriptionInfo] object. We set up a
+ * repository for each individual, tracked subscription via [MobileConnectionsRepository], and this
+ * repository is responsible for setting up a [TelephonyManager] object tied to its subscriptionId
+ *
+ * There should only ever be one [MobileConnectionRepository] per subscription, since
+ * [TelephonyManager] limits the number of callbacks that can be registered per process.
+ *
+ * This repository should have all of the relevant information for a single line of service, which
+ * eventually becomes a single icon in the status bar.
+ */
+@ExperimentalKairosApi
+interface MobileConnectionRepositoryKairos {
+ /** The subscriptionId that this connection represents */
+ val subId: Int
+
+ /** The carrierId for this connection. See [TelephonyManager.getSimCarrierId] */
+ val carrierId: State<Int>
+
+ /** Reflects the value from the carrier config INFLATE_SIGNAL_STRENGTH for this connection */
+ val inflateSignalStrength: State<Boolean>
+
+ /** Carrier config KEY_SHOW_5G_SLICE_ICON_BOOL for this connection */
+ val allowNetworkSliceIndicator: State<Boolean>
+
+ /**
+ * The table log buffer created for this connection. Will have the name "MobileConnectionLog
+ * [subId]"
+ */
+ val tableLogBuffer: TableLogBuffer
+
+ /** True if the [android.telephony.ServiceState] says this connection is emergency calls only */
+ val isEmergencyOnly: State<Boolean>
+
+ /** True if [android.telephony.ServiceState] says we are roaming */
+ val isRoaming: State<Boolean>
+
+ /**
+ * See [android.telephony.ServiceState.getOperatorAlphaShort], this value is defined as the
+ * current registered operator name in short alphanumeric format. In some cases this name might
+ * be preferred over other methods of calculating the network name
+ */
+ val operatorAlphaShort: State<String?>
+
+ /**
+ * TODO (b/263167683): Clarify this field
+ *
+ * This check comes from [com.android.settingslib.Utils.isInService]. It is intended to be a
+ * mapping from a ServiceState to a notion of connectivity. Notably, it will consider a
+ * connection to be in-service if either the voice registration state is IN_SERVICE or the data
+ * registration state is IN_SERVICE and NOT IWLAN.
+ */
+ val isInService: State<Boolean>
+
+ /**
+ * True if this subscription is actively connected to a non-terrestrial network and false
+ * otherwise. Reflects [android.telephony.ServiceState.isUsingNonTerrestrialNetwork].
+ *
+ * Notably: This value reflects that this subscription is **currently** using a non-terrestrial
+ * network, because some subscriptions can switch between terrestrial and non-terrestrial
+ * networks. [SubscriptionModel.isExclusivelyNonTerrestrial] reflects whether a subscription is
+ * configured to exclusively connect to non-terrestrial networks. [isNonTerrestrial] can change
+ * during the lifetime of a subscription but [SubscriptionModel.isExclusivelyNonTerrestrial]
+ * will stay constant.
+ */
+ val isNonTerrestrial: State<Boolean>
+
+ /** True if [android.telephony.SignalStrength] told us that this connection is using GSM */
+ val isGsm: State<Boolean>
+
+ /**
+ * There is still specific logic in the pipeline that calls out CDMA level explicitly. This
+ * field is not completely orthogonal to [primaryLevel], because CDMA could be primary.
+ */
+ // @IntRange(from = 0, to = 4)
+ val cdmaLevel: State<Int>
+
+ /** [android.telephony.SignalStrength]'s concept of the overall signal level */
+ // @IntRange(from = 0, to = 4)
+ val primaryLevel: State<Int>
+
+ /**
+ * This level can be used to reflect the signal strength when in carrier roaming NTN mode
+ * (carrier-based satellite)
+ */
+ val satelliteLevel: State<Int>
+
+ /** The current data connection state. See [DataConnectionState] */
+ val dataConnectionState: State<DataConnectionState>
+
+ /** The current data activity direction. See [DataActivityModel] */
+ val dataActivityDirection: State<DataActivityModel>
+
+ /** True if there is currently a carrier network change in process */
+ val carrierNetworkChangeActive: State<Boolean>
+
+ /**
+ * [resolvedNetworkType] is the [TelephonyDisplayInfo.getOverrideNetworkType] if it exists or
+ * [TelephonyDisplayInfo.getNetworkType]. This is used to look up the proper network type icon
+ */
+ val resolvedNetworkType: State<ResolvedNetworkType>
+
+ /** The total number of levels. Used with [SignalDrawable]. */
+ val numberOfLevels: State<Int>
+
+ /** Observable tracking [TelephonyManager.isDataConnectionAllowed] */
+ val dataEnabled: State<Boolean>
+
+ /**
+ * See [TelephonyManager.getCdmaEnhancedRoamingIndicatorDisplayNumber]. This bit only matters if
+ * the connection type is CDMA.
+ *
+ * True if the Enhanced Roaming Indicator (ERI) display number is not [TelephonyManager.ERI_OFF]
+ */
+ val cdmaRoaming: State<Boolean>
+
+ /** The service provider name for this network connection, or the default name. */
+ val networkName: State<NetworkNameModel>
+
+ /**
+ * The service provider name for this network connection, or the default name.
+ *
+ * TODO(b/296600321): De-duplicate this field with [networkName] after determining the data
+ * provided is identical
+ */
+ val carrierName: State<NetworkNameModel>
+
+ /**
+ * True if this type of connection is allowed while airplane mode is on, and false otherwise.
+ */
+ val isAllowedDuringAirplaneMode: State<Boolean>
+
+ /**
+ * True if this network has NET_CAPABILITIY_PRIORITIZE_LATENCY, and can be considered to be a
+ * network slice
+ */
+ val hasPrioritizedNetworkCapabilities: State<Boolean>
+
+ /**
+ * True if this connection is in emergency callback mode.
+ *
+ * @see [TelephonyManager.getEmergencyCallbackMode]
+ */
+ val isInEcmMode: State<Boolean>
+
+ companion object {
+ /** The default number of levels to use for [numberOfLevels]. */
+ val DEFAULT_NUM_LEVELS = CellSignalStrength.getNumSignalStrengthLevels()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt
new file mode 100644
index 000000000000..79bfb6e48171
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairos.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.telephony.CarrierConfigManager
+import android.telephony.SubscriptionManager
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.MobileMappings
+import com.android.settingslib.mobile.MobileMappings.Config
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+
+/**
+ * Repo for monitoring the complete active subscription info list, to be consumed and filtered based
+ * on various policy
+ */
+@ExperimentalKairosApi
+interface MobileConnectionsRepositoryKairos {
+
+ /** All active mobile connections. */
+ val mobileConnectionsBySubId: Incremental<Int, MobileConnectionRepositoryKairos>
+
+ /** Observable list of current mobile subscriptions */
+ val subscriptions: State<Collection<SubscriptionModel>>
+
+ /**
+ * Observable for the subscriptionId of the current mobile data connection. Null if we don't
+ * have a valid subscription id
+ */
+ val activeMobileDataSubscriptionId: State<Int?>
+
+ /** Repo that tracks the current [activeMobileDataSubscriptionId] */
+ val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?>
+
+ /**
+ * Observable event for when the active data sim switches but the group stays the same. E.g.,
+ * CBRS switching would trigger this
+ */
+ val activeSubChangedInGroupEvent: Events<Unit>
+
+ /** Tracks [SubscriptionManager.getDefaultDataSubscriptionId]. `null` if there is no default. */
+ val defaultDataSubId: State<Int?>
+
+ /**
+ * True if the default network connection is a mobile-like connection and false otherwise.
+ *
+ * This is typically shown by having [android.net.NetworkCapabilities.TRANSPORT_CELLULAR], but
+ * there are edge cases (like carrier merged wifi) that could also result in the default
+ * connection being mobile-like.
+ */
+ val mobileIsDefault: State<Boolean>
+
+ /**
+ * True if the device currently has a carrier merged connection.
+ *
+ * See [CarrierMergedConnectionRepository] for more info.
+ */
+ val hasCarrierMergedConnection: State<Boolean>
+
+ /** True if the default network connection is validated and false otherwise. */
+ val defaultConnectionIsValidated: State<Boolean>
+
+ /**
+ * [Config] is an object that tracks relevant configuration flags for a given subscription ID.
+ * In the case of [MobileMappings], it's hard-coded to check the default data subscription's
+ * config, so this will apply to every icon that we care about.
+ *
+ * Relevant bits in the config are things like
+ * [CarrierConfigManager.KEY_SHOW_4G_FOR_LTE_DATA_ICON_BOOL]
+ *
+ * This flow will produce whenever the default data subscription or the carrier config changes.
+ */
+ val defaultDataSubRatConfig: State<Config>
+
+ /** The icon mapping from network type to [MobileIconGroup] for the default subscription */
+ val defaultMobileIconMapping: State<Map<String, MobileIconGroup>>
+
+ /** Fallback [MobileIconGroup] in the case where there is no icon in the mapping */
+ val defaultMobileIconGroup: State<MobileIconGroup>
+
+ /**
+ * Can the device make emergency calls using the device-based service state? This field is only
+ * useful when all known active subscriptions are OOS and not emergency call capable.
+ *
+ * Specifically, this checks every [ServiceState] of the device, and looks for any that report
+ * [ServiceState.isEmergencyOnly].
+ *
+ * This is an eager flow, and re-evaluates whenever ACTION_SERVICE_STATE is sent for subId = -1.
+ */
+ val isDeviceEmergencyCallCapable: State<Boolean>
+
+ /**
+ * If any active SIM on the device is in
+ * [android.telephony.TelephonyManager.SIM_STATE_PIN_REQUIRED] or
+ * [android.telephony.TelephonyManager.SIM_STATE_PUK_REQUIRED] or
+ * [android.telephony.TelephonyManager.SIM_STATE_PERM_DISABLED]
+ */
+ val isAnySimSecure: State<Boolean>
+
+ /**
+ * Checks if any subscription has [android.telephony.TelephonyManager.getEmergencyCallbackMode]
+ * == true
+ */
+ val isInEcmMode: State<Boolean>
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt
new file mode 100644
index 000000000000..64144d9a0ab5
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryKairosAdapter.kt
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.content.Context
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairos.toColdConflatedFlow
+import com.android.systemui.kairosBuilder
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionRepositoryKairosAdapter
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+
+@ExperimentalKairosApi
+@SysUISingleton
+class MobileConnectionsRepositoryKairosAdapter
+@Inject
+constructor(
+ private val kairosRepo: MobileConnectionsRepositoryKairos,
+ private val kairosNetwork: KairosNetwork,
+ @Application scope: CoroutineScope,
+ connectivityRepository: ConnectivityRepository,
+ context: Context,
+ carrierConfigRepo: CarrierConfigRepository,
+) : MobileConnectionsRepository, KairosBuilder by kairosBuilder() {
+ override val subscriptions: StateFlow<List<SubscriptionModel>> =
+ kairosRepo.subscriptions
+ .map { it.toList() }
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
+
+ override val activeMobileDataSubscriptionId: StateFlow<Int?> =
+ kairosRepo.activeMobileDataSubscriptionId
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ private val reposBySubIdK = buildIncremental {
+ kairosRepo.mobileConnectionsBySubId
+ .mapValues { (subId, repo) ->
+ buildSpec {
+ MobileConnectionRepositoryKairosAdapter(
+ kairosRepo = repo,
+ carrierConfig = carrierConfigRepo.getOrCreateConfigForSubId(subId),
+ )
+ }
+ }
+ .applyLatestSpecForKey()
+ }
+
+ private val reposBySubId =
+ reposBySubIdK
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.Eagerly, emptyMap())
+
+ override val activeMobileDataRepository: StateFlow<MobileConnectionRepository?> =
+ combine(kairosRepo.activeMobileDataSubscriptionId, reposBySubIdK) { id, repos -> repos[id] }
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ override val activeSubChangedInGroupEvent: Flow<Unit> =
+ kairosRepo.activeSubChangedInGroupEvent.toColdConflatedFlow(kairosNetwork)
+
+ override val defaultDataSubId: StateFlow<Int?> =
+ kairosRepo.defaultDataSubId
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ override val mobileIsDefault: StateFlow<Boolean> =
+ kairosRepo.mobileIsDefault
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectivityRepository.defaultConnections.value.mobile.isDefault,
+ )
+
+ override val hasCarrierMergedConnection: Flow<Boolean> =
+ kairosRepo.hasCarrierMergedConnection.toColdConflatedFlow(kairosNetwork)
+
+ override val defaultConnectionIsValidated: StateFlow<Boolean> =
+ kairosRepo.defaultConnectionIsValidated
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectivityRepository.defaultConnections.value.isValidated,
+ )
+
+ override fun getRepoForSubId(subId: Int): MobileConnectionRepository =
+ reposBySubId.value[subId] ?: error("Unknown subscription id: $subId")
+
+ override val defaultDataSubRatConfig: StateFlow<MobileMappings.Config> =
+ kairosRepo.defaultDataSubRatConfig
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ MobileMappings.Config.readConfig(context),
+ )
+
+ override val defaultMobileIconMapping: Flow<Map<String, SignalIcon.MobileIconGroup>> =
+ kairosRepo.defaultMobileIconMapping.toColdConflatedFlow(kairosNetwork)
+
+ override val defaultMobileIconGroup: Flow<SignalIcon.MobileIconGroup> =
+ kairosRepo.defaultMobileIconGroup.toColdConflatedFlow(kairosNetwork)
+
+ override val isDeviceEmergencyCallCapable: StateFlow<Boolean> =
+ kairosRepo.isDeviceEmergencyCallCapable
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override val isAnySimSecure: StateFlow<Boolean> =
+ kairosRepo.isAnySimSecure
+ .toColdConflatedFlow(kairosNetwork)
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun getIsAnySimSecure(): Boolean = isAnySimSecure.value
+
+ override suspend fun isInEcmMode(): Boolean =
+ kairosNetwork.transact { kairosRepo.isInEcmMode.sample() }
+
+ @dagger.Module
+ object Module {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<MobileConnectionsRepositoryKairosAdapter>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt
index 66587c779fbe..caf4bf502c06 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt
@@ -48,9 +48,9 @@ import kotlinx.coroutines.flow.stateIn
* something like this:
* ```
* RealRepository
- * │
- * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel
- * │
+ * │
+ * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel
+ * │
* DemoRepository
* ```
*
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt
new file mode 100644
index 000000000000..1f5b849c56cc
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherKairos.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.os.Bundle
+import androidx.annotation.VisibleForTesting
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.activated
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.demomode.DemoMode
+import com.android.systemui.demomode.DemoModeController
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.flatMap
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.switchEvents
+import com.android.systemui.kairos.switchIncremental
+import com.android.systemui.kairosBuilder
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryKairosImpl
+import dagger.Binds
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.channels.awaitClose
+
+/**
+ * A provider for the [MobileConnectionsRepository] interface that can choose between the Demo and
+ * Prod concrete implementations at runtime. It works by defining a base flow, [activeRepo], which
+ * switches based on the latest information from [DemoModeController], and switches every flow in
+ * the interface to point to the currently-active provider. This allows us to put the demo mode
+ * interface in its own repository, completely separate from the real version, while still using all
+ * of the prod implementations for the rest of the pipeline (interactors and onward). Looks
+ * something like this:
+ * ```
+ * RealRepository
+ * │
+ * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel
+ * │
+ * DemoRepository
+ * ```
+ *
+ * NOTE: because the UI layer for mobile icons relies on a nested-repository structure, it is likely
+ * that we will have to drain the subscription list whenever demo mode changes. Otherwise if a real
+ * subscription list [1] is replaced with a demo subscription list [1], the view models will not see
+ * a change (due to `distinctUntilChanged`) and will not refresh their data providers to the demo
+ * implementation.
+ */
+@ExperimentalKairosApi
+@SysUISingleton
+class MobileRepositorySwitcherKairos
+@Inject
+constructor(
+ private val realRepository: MobileConnectionsRepositoryKairosImpl,
+ private val demoRepositoryFactory: DemoMobileConnectionsRepositoryKairos.Factory,
+ demoModeController: DemoModeController,
+) : MobileConnectionsRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ private val isDemoMode: State<Boolean> = buildState {
+ conflatedCallbackFlow {
+ val callback =
+ object : DemoMode {
+ override fun dispatchDemoCommand(command: String?, args: Bundle?) {
+ // Nothing, we just care about on/off
+ }
+
+ override fun onDemoModeStarted() {
+ trySend(true)
+ }
+
+ override fun onDemoModeFinished() {
+ trySend(false)
+ }
+ }
+
+ demoModeController.addCallback(callback)
+ awaitClose { demoModeController.removeCallback(callback) }
+ }
+ .toState(demoModeController.isInDemoMode)
+ }
+
+ // Convenient definition flow for the currently active repo (based on demo mode or not)
+ @VisibleForTesting
+ val activeRepo: State<MobileConnectionsRepositoryKairos> = buildState {
+ isDemoMode.mapLatestBuild { demoMode ->
+ if (demoMode) {
+ activated { demoRepositoryFactory.create() }
+ } else {
+ realRepository
+ }
+ }
+ }
+
+ override val mobileConnectionsBySubId: Incremental<Int, MobileConnectionRepositoryKairos> =
+ activeRepo.map { it.mobileConnectionsBySubId }.switchIncremental()
+
+ override val subscriptions: State<Collection<SubscriptionModel>> =
+ activeRepo.flatMap { it.subscriptions }
+
+ override val activeMobileDataSubscriptionId: State<Int?> =
+ activeRepo.flatMap { it.activeMobileDataSubscriptionId }
+
+ override val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?> =
+ activeRepo.flatMap { it.activeMobileDataRepository }
+
+ override val activeSubChangedInGroupEvent: Events<Unit> =
+ activeRepo.map { it.activeSubChangedInGroupEvent }.switchEvents()
+
+ override val defaultDataSubRatConfig: State<MobileMappings.Config> =
+ activeRepo.flatMap { it.defaultDataSubRatConfig }
+
+ override val defaultMobileIconMapping: State<Map<String, SignalIcon.MobileIconGroup>> =
+ activeRepo.flatMap { it.defaultMobileIconMapping }
+
+ override val defaultMobileIconGroup: State<SignalIcon.MobileIconGroup> =
+ activeRepo.flatMap { it.defaultMobileIconGroup }
+
+ override val isDeviceEmergencyCallCapable: State<Boolean> =
+ activeRepo.flatMap { it.isDeviceEmergencyCallCapable }
+
+ override val isAnySimSecure: State<Boolean> = activeRepo.flatMap { it.isAnySimSecure }
+
+ override val defaultDataSubId: State<Int?> = activeRepo.flatMap { it.defaultDataSubId }
+
+ override val mobileIsDefault: State<Boolean> = activeRepo.flatMap { it.mobileIsDefault }
+
+ override val hasCarrierMergedConnection: State<Boolean> =
+ activeRepo.flatMap { it.hasCarrierMergedConnection }
+
+ override val defaultConnectionIsValidated: State<Boolean> =
+ activeRepo.flatMap { it.defaultConnectionIsValidated }
+
+ override val isInEcmMode: State<Boolean> = activeRepo.flatMap { it.isInEcmMode }
+
+ @dagger.Module
+ interface Module {
+ @Binds fun bindImpl(impl: MobileRepositorySwitcherKairos): MobileConnectionsRepositoryKairos
+
+ companion object {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<MobileRepositorySwitcherKairos>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..a244feb1739a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepositoryKairos.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright (C) 2024 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.data.repository.demo
+
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager
+import com.android.settingslib.SignalIcon
+import com.android.systemui.KairosBuilder
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.TransactionScope
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapCheap
+import com.android.systemui.kairos.mergeLeft
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairos.util.Either
+import com.android.systemui.kairos.util.Either.First
+import com.android.systemui.kairos.util.Either.Second
+import com.android.systemui.kairos.util.firstOrNull
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile as FakeMobileEvent
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_CARRIER_ID
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_CARRIER_NETWORK_CHANGE
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_CDMA_LEVEL
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_IS_IN_SERVICE
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_IS_NTN
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_OPERATOR
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_PRIMARY_LEVEL
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_ROAMING
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepositoryKairos.Companion.COL_SATELLITE_LEVEL
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel.CarrierMerged as FakeCarrierMergedEvent
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/**
+ * Demo version of [MobileConnectionRepository]. Note that this class shares all of its flows using
+ * [SharingStarted.WhileSubscribed()] to give the same semantics as using a regular
+ * [MutableStateFlow] while still logging all of the inputs in the same manor as the production
+ * repos.
+ */
+@ExperimentalKairosApi
+class DemoMobileConnectionRepositoryKairos(
+ override val subId: Int,
+ override val tableLogBuffer: TableLogBuffer,
+ mobileEvents: Events<FakeMobileEvent>,
+ carrierMergedResetEvents: Events<Any?>,
+ wifiEvents: Events<FakeCarrierMergedEvent>,
+ private val mobileMappingsReverseLookup: State<Map<SignalIcon.MobileIconGroup, String>>,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ private val initialState =
+ FakeMobileEvent(
+ level = null,
+ dataType = null,
+ subId = subId,
+ carrierId = null,
+ activity = null,
+ carrierNetworkChange = false,
+ roaming = false,
+ name = DEMO_CARRIER_NAME,
+ )
+
+ private val lastMobileEvent: State<FakeMobileEvent> = buildState {
+ mobileEvents.holdState(initialState)
+ }
+
+ private val lastEvent: State<Either<FakeMobileEvent, FakeCarrierMergedEvent>> = buildState {
+ mergeLeft(
+ mobileEvents.mapCheap { First(it) },
+ wifiEvents.mapCheap { Second(it) },
+ carrierMergedResetEvents.mapCheap { First(lastMobileEvent.sample()) },
+ )
+ .holdState(First(initialState))
+ }
+
+ override val carrierId: State<Int> =
+ lastEvent
+ .map { it.firstOrNull()?.carrierId ?: INVALID_SUBSCRIPTION_ID }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ intState = it,
+ tableLogBuffer = tableLogBuffer,
+ columnName = COL_CARRIER_ID,
+ )
+ }
+ }
+
+ override val inflateSignalStrength: State<Boolean> = buildState {
+ mobileEvents
+ .map { ev -> ev.inflateStrength }
+ .holdState(false)
+ .also { logDiffsForTable(it, tableLogBuffer, "", columnName = "inflate") }
+ }
+
+ // I don't see a reason why we would turn the config off for demo mode.
+ override val allowNetworkSliceIndicator: State<Boolean> = stateOf(true)
+
+ // TODO(b/261029387): not yet supported
+ override val isEmergencyOnly: State<Boolean> = stateOf(false)
+
+ override val isRoaming: State<Boolean> =
+ lastEvent
+ .map { it.firstOrNull()?.roaming ?: false }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_ROAMING) } }
+
+ override val operatorAlphaShort: State<String?> =
+ lastEvent
+ .map { it.firstOrNull()?.name }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_OPERATOR) }
+ }
+
+ override val isInService: State<Boolean> =
+ lastEvent
+ .map {
+ when (it) {
+ is First -> it.value.level?.let { level -> level > 0 } ?: false
+ is Second -> true
+ }
+ }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_IN_SERVICE) }
+ }
+
+ override val isNonTerrestrial: State<Boolean> = buildState {
+ mobileEvents
+ .map { it.ntn }
+ .holdState(false)
+ .also { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_NTN) }
+ }
+
+ // TODO(b/261029387): not yet supported
+ override val isGsm: State<Boolean> = stateOf(false)
+
+ override val cdmaLevel: State<Int> =
+ lastEvent
+ .map {
+ when (it) {
+ is First -> it.value.level ?: 0
+ is Second -> it.value.level
+ }
+ }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_CDMA_LEVEL) }
+ }
+
+ override val primaryLevel: State<Int> =
+ lastEvent
+ .map {
+ when (it) {
+ is First -> it.value.level ?: 0
+ is Second -> it.value.level
+ }
+ }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_PRIMARY_LEVEL) }
+ }
+
+ override val satelliteLevel: State<Int> =
+ stateOf(0).also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_SATELLITE_LEVEL) }
+ }
+
+ // TODO(b/261029387): not yet supported
+ override val dataConnectionState: State<DataConnectionState> =
+ buildState {
+ mergeLeft(mobileEvents, wifiEvents)
+ .map { DataConnectionState.Connected }
+ .holdState(DataConnectionState.Disconnected)
+ }
+ .also {
+ onActivated {
+ logDiffsForTable(diffableState = it, tableLogBuffer = tableLogBuffer)
+ }
+ }
+
+ override val dataActivityDirection: State<DataActivityModel> =
+ lastEvent
+ .map {
+ val activity =
+ when (it) {
+ is First -> it.value.activity ?: TelephonyManager.DATA_ACTIVITY_NONE
+ is Second -> it.value.activity
+ }
+ activity.toMobileDataActivityModel()
+ }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val carrierNetworkChangeActive: State<Boolean> =
+ lastEvent
+ .map { it.firstOrNull()?.carrierNetworkChange ?: false }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogBuffer, columnName = COL_CARRIER_NETWORK_CHANGE)
+ }
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> = buildState {
+ lastEvent
+ .mapTransactionally {
+ it.firstOrNull()?.dataType?.let { resolvedNetworkTypeForIconGroup(it) }
+ ?: ResolvedNetworkType.CarrierMergedNetworkType
+ }
+ .also { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") }
+ }
+
+ override val numberOfLevels: State<Int> =
+ inflateSignalStrength.map { shouldInflate ->
+ if (shouldInflate) DEFAULT_NUM_LEVELS + 1 else DEFAULT_NUM_LEVELS
+ }
+
+ override val dataEnabled: State<Boolean> = stateOf(true)
+
+ override val cdmaRoaming: State<Boolean> = lastEvent.map { it.firstOrNull()?.roaming ?: false }
+
+ override val networkName: State<NetworkNameModel.IntentDerived> =
+ lastEvent.map {
+ NetworkNameModel.IntentDerived(it.firstOrNull()?.name ?: CARRIER_MERGED_NAME)
+ }
+
+ override val carrierName: State<NetworkNameModel.SubscriptionDerived> =
+ lastEvent.map {
+ NetworkNameModel.SubscriptionDerived(
+ it.firstOrNull()?.let { event -> "${event.name} ${event.subId}" }
+ ?: CARRIER_MERGED_NAME
+ )
+ }
+
+ override val isAllowedDuringAirplaneMode: State<Boolean> = lastEvent.map { it is Second }
+
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> =
+ lastEvent.map { it.firstOrNull()?.slice ?: false }
+
+ override val isInEcmMode: State<Boolean> = stateOf(false)
+
+ private fun TransactionScope.resolvedNetworkTypeForIconGroup(
+ iconGroup: SignalIcon.MobileIconGroup?
+ ) = DefaultNetworkType(mobileMappingsReverseLookup.sample()[iconGroup] ?: "dis")
+
+ companion object {
+ private const val DEMO_CARRIER_NAME = "Demo Carrier"
+ private const val CARRIER_MERGED_NAME = "Carrier Merged Network"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt
new file mode 100644
index 000000000000..925ee541bf73
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryKairos.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2024 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.data.repository.demo
+
+import android.content.Context
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.SubscriptionManager.PROFILE_CLASS_UNSET
+import android.util.Log
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.KairosBuilder
+import com.android.systemui.activated
+import com.android.systemui.kairos.BuildScope
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.GroupedEvents
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.TransactionScope
+import com.android.systemui.kairos.asIncremental
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.emptyEvents
+import com.android.systemui.kairos.filter
+import com.android.systemui.kairos.filterIsInstance
+import com.android.systemui.kairos.groupBy
+import com.android.systemui.kairos.groupByKey
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapCheap
+import com.android.systemui.kairos.mapNotNull
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairos.mergeLeft
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBufferFactory
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Factory.Companion.MOBILE_CONNECTION_BUFFER_SIZE
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.DemoModeWifiDataSource
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.model.FakeWifiEventModel
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+/** This repository vends out data based on demo mode commands */
+@ExperimentalKairosApi
+class DemoMobileConnectionsRepositoryKairos
+@AssistedInject
+constructor(
+ mobileDataSource: DemoModeMobileConnectionDataSourceKairos,
+ private val wifiDataSource: DemoModeWifiDataSource,
+ context: Context,
+ private val logFactory: TableLogBufferFactory,
+) : MobileConnectionsRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(): DemoMobileConnectionsRepositoryKairos
+ }
+
+ private val wifiEvents: Events<FakeWifiEventModel?> = buildEvents {
+ wifiDataSource.wifiEvents.toEvents()
+ }
+
+ private val mobileEventsWithSubId: Events<Pair<Int, FakeNetworkEventModel>> =
+ mobileDataSource.mobileEvents.mapNotNull { event ->
+ event?.let { (event.subId ?: lastSeenSubId.sample())?.let { it to event } }
+ }
+
+ private val mobileEventsBySubId: GroupedEvents<Int, FakeNetworkEventModel> =
+ mobileEventsWithSubId.map { mapOf(it) }.groupByKey()
+
+ private val carrierMergedEvents: Events<FakeWifiEventModel.CarrierMerged> =
+ wifiEvents.filterIsInstance<FakeWifiEventModel.CarrierMerged>()
+
+ private val wifiEventsBySubId: GroupedEvents<Int, FakeWifiEventModel.CarrierMerged> =
+ carrierMergedEvents.groupBy { it.subscriptionId }
+
+ private val lastSeenSubId: State<Int?> = buildState {
+ mergeLeft(
+ mobileEventsWithSubId.mapCheap { it.first },
+ carrierMergedEvents.mapCheap { it.subscriptionId },
+ )
+ .holdState(null)
+ }
+
+ private val activeCarrierMergedSubscription: State<Int?> = buildState {
+ mergeLeft(
+ carrierMergedEvents.mapCheap { it.subscriptionId },
+ wifiEvents
+ .filter {
+ it is FakeWifiEventModel.Wifi || it is FakeWifiEventModel.WifiDisabled
+ }
+ .map { null },
+ )
+ .holdState(null)
+ }
+
+ private val activeMobileSubscriptions: State<Set<Int>> = buildState {
+ mobileDataSource.mobileEvents
+ .mapNotNull { event ->
+ when (event) {
+ null -> null
+ is Mobile -> event.subId?.let { subId -> { subs: Set<Int> -> subs + subId } }
+ is MobileDisabled ->
+ (event.subId ?: maybeGetOnlySubIdForRemoval())?.let { subId ->
+ { subs: Set<Int> -> subs - subId }
+ }
+ }
+ }
+ .foldState(emptySet()) { f, s -> f(s) }
+ }
+
+ private val subscriptionIds: State<Set<Int>> =
+ combine(activeMobileSubscriptions, activeCarrierMergedSubscription) { mobile, carrierMerged
+ ->
+ carrierMerged?.let { mobile + carrierMerged } ?: mobile
+ }
+
+ private val subscriptionsById: State<Map<Int, SubscriptionModel>> =
+ subscriptionIds.map { subs ->
+ subs.associateWith { subId ->
+ SubscriptionModel(
+ subscriptionId = subId,
+ isOpportunistic = false,
+ carrierName = DEFAULT_CARRIER_NAME,
+ profileClass = PROFILE_CLASS_UNSET,
+ )
+ }
+ }
+
+ override val subscriptions: State<Collection<SubscriptionModel>> =
+ subscriptionsById.map { it.values }
+
+ private fun TransactionScope.maybeGetOnlySubIdForRemoval(): Int? {
+ val subIds = activeMobileSubscriptions.sample()
+ return if (subIds.size == 1) {
+ subIds.first()
+ } else {
+ Log.d(
+ TAG,
+ "processDisabledMobileState: Unable to infer subscription to " +
+ "disable. Specify subId using '-e slot <subId>'. " +
+ "Known subIds: [${subIds.joinToString(",")}]",
+ )
+ null
+ }
+ }
+
+ private val reposBySubId: Incremental<Int, DemoMobileConnectionRepositoryKairos> =
+ buildIncremental {
+ subscriptionsById
+ .asIncremental()
+ .mapValues { (id, _) -> buildSpec { newRepo(id) } }
+ .applyLatestSpecForKey()
+ }
+
+ // TODO(b/261029387): add a command for this value
+ override val activeMobileDataSubscriptionId: State<Int> =
+ // For now, active is just the first in the list
+ subscriptions.map { infos ->
+ infos.firstOrNull()?.subscriptionId ?: INVALID_SUBSCRIPTION_ID
+ }
+
+ override val activeMobileDataRepository: State<DemoMobileConnectionRepositoryKairos?> =
+ combine(activeMobileDataSubscriptionId, reposBySubId) { subId, repoMap -> repoMap[subId] }
+
+ // TODO(b/261029387): consider adding a demo command for this
+ override val activeSubChangedInGroupEvent: Events<Unit> = emptyEvents
+
+ /** Demo mode doesn't currently support modifications to the mobile mappings */
+ override val defaultDataSubRatConfig: State<MobileMappings.Config> =
+ stateOf(MobileMappings.Config.readConfig(context))
+
+ override val defaultMobileIconGroup: State<SignalIcon.MobileIconGroup> =
+ stateOf(TelephonyIcons.THREE_G)
+
+ // TODO(b/339023069): demo command for device-based emergency calls state
+ override val isDeviceEmergencyCallCapable: State<Boolean> = stateOf(false)
+
+ override val isAnySimSecure: State<Boolean> = stateOf(false)
+
+ override val defaultMobileIconMapping: State<Map<String, SignalIcon.MobileIconGroup>> =
+ stateOf(TelephonyIcons.ICON_NAME_TO_ICON)
+
+ /**
+ * In order to maintain compatibility with the old demo mode shell command API, reverse the
+ * [MobileMappings] lookup from (NetworkType: String -> Icon: MobileIconGroup), so that we can
+ * parse the string from the command line into a preferred icon group, and send _a_ valid
+ * network type for that icon through the pipeline.
+ *
+ * Note: collisions don't matter here, because the data source (the command line) only cares
+ * about the resulting icon, not the underlying network type.
+ */
+ private val mobileMappingsReverseLookup: State<Map<SignalIcon.MobileIconGroup, String>> =
+ defaultMobileIconMapping.map { networkToIconMap -> networkToIconMap.reverse() }
+
+ private fun <K, V> Map<K, V>.reverse() = entries.associate { (k, v) -> v to k }
+
+ // TODO(b/261029387): add a command for this value
+ override val defaultDataSubId: State<Int?> = stateOf(null)
+
+ // TODO(b/261029387): not yet supported
+ override val mobileIsDefault: State<Boolean> = stateOf(true)
+
+ // TODO(b/261029387): not yet supported
+ override val hasCarrierMergedConnection: State<Boolean> = stateOf(false)
+
+ // TODO(b/261029387): not yet supported
+ override val defaultConnectionIsValidated: State<Boolean> = stateOf(true)
+
+ override val isInEcmMode: State<Boolean> = stateOf(false)
+
+ override val mobileConnectionsBySubId: Incremental<Int, DemoMobileConnectionRepositoryKairos>
+ get() = reposBySubId
+
+ private fun BuildScope.newRepo(subId: Int) = activated {
+ DemoMobileConnectionRepositoryKairos(
+ subId = subId,
+ tableLogBuffer =
+ logFactory.getOrCreate(
+ "DemoMobileConnectionLog[$subId]",
+ MOBILE_CONNECTION_BUFFER_SIZE,
+ ),
+ mobileEvents = mobileEventsBySubId[subId].filterIsInstance(),
+ carrierMergedResetEvents =
+ wifiEvents.mapNotNull { it?.takeIf { it !is FakeWifiEventModel.CarrierMerged } },
+ wifiEvents = wifiEventsBySubId[subId],
+ mobileMappingsReverseLookup = mobileMappingsReverseLookup,
+ )
+ }
+
+ companion object {
+ private const val TAG = "DemoMobileConnectionsRepo"
+
+ private const val DEFAULT_CARRIER_NAME = "demo carrier"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt
new file mode 100644
index 000000000000..f32938335e6d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSourceKairos.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2024 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.data.repository.demo
+
+import android.os.Bundle
+import android.telephony.Annotation.DataActivityType
+import android.telephony.TelephonyManager.DATA_ACTIVITY_IN
+import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT
+import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE
+import android.telephony.TelephonyManager.DATA_ACTIVITY_OUT
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.demomode.DemoMode.COMMAND_NETWORK
+import com.android.systemui.demomode.DemoModeController
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairosBuilder
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled
+import dagger.Binds
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * Data source that can map from demo mode commands to inputs into the
+ * [DemoMobileConnectionsRepositoryKairos]
+ */
+@ExperimentalKairosApi
+interface DemoModeMobileConnectionDataSourceKairos {
+ val mobileEvents: Events<FakeNetworkEventModel?>
+}
+
+@ExperimentalKairosApi
+@SysUISingleton
+class DemoModeMobileConnectionDataSourceKairosImpl
+@Inject
+constructor(demoModeController: DemoModeController) :
+ KairosBuilder by kairosBuilder(), DemoModeMobileConnectionDataSourceKairos {
+ private val demoCommandStream: Flow<Bundle> =
+ demoModeController.demoFlowForCommand(COMMAND_NETWORK)
+
+ // If the args contains "mobile", then all of the args are relevant. It's just the way demo mode
+ // commands work and it's a little silly
+ private val _mobileCommands: Flow<FakeNetworkEventModel?> =
+ demoCommandStream.map { args -> args.toMobileEvent() }
+ override val mobileEvents: Events<FakeNetworkEventModel?> = buildEvents {
+ _mobileCommands.toEvents()
+ }
+
+ private fun Bundle.toMobileEvent(): FakeNetworkEventModel? {
+ val mobile = getString("mobile") ?: return null
+ return if (mobile == "show") {
+ activeMobileEvent()
+ } else {
+ MobileDisabled(subId = getString("slot")?.toInt())
+ }
+ }
+
+ /** Parse a valid mobile command string into a network event */
+ private fun Bundle.activeMobileEvent(): Mobile {
+ // There are many key/value pairs supported by mobile demo mode. Bear with me here
+ val level = getString("level")?.toInt()
+ val dataType = getString("datatype")?.toDataType()
+ val slot = getString("slot")?.toInt()
+ val carrierId = getString("carrierid")?.toInt()
+ val inflateStrength = getString("inflate").toBoolean()
+ val activity = getString("activity")?.toActivity()
+ val carrierNetworkChange = getString("carriernetworkchange") == "show"
+ val roaming = getString("roam") == "show"
+ val name = getString("networkname") ?: "demo mode"
+ val slice = getString("slice").toBoolean()
+ val ntn = getString("ntn").toBoolean()
+
+ return Mobile(
+ level = level,
+ dataType = dataType,
+ subId = slot,
+ carrierId = carrierId,
+ inflateStrength = inflateStrength,
+ activity = activity,
+ carrierNetworkChange = carrierNetworkChange,
+ roaming = roaming,
+ name = name,
+ slice = slice,
+ ntn = ntn,
+ )
+ }
+
+ @dagger.Module
+ interface Module {
+ @Binds
+ fun bindImpl(
+ impl: DemoModeMobileConnectionDataSourceKairosImpl
+ ): DemoModeMobileConnectionDataSourceKairos
+
+ companion object {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<DemoModeMobileConnectionDataSourceKairosImpl>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+ }
+}
+
+private fun String.toDataType(): MobileIconGroup =
+ when (this) {
+ "1x" -> TelephonyIcons.ONE_X
+ "3g" -> TelephonyIcons.THREE_G
+ "4g" -> TelephonyIcons.FOUR_G
+ "4g+" -> TelephonyIcons.FOUR_G_PLUS
+ "5g" -> TelephonyIcons.NR_5G
+ "5ge" -> TelephonyIcons.LTE_CA_5G_E
+ "5g+" -> TelephonyIcons.NR_5G_PLUS
+ "e" -> TelephonyIcons.E
+ "g" -> TelephonyIcons.G
+ "h" -> TelephonyIcons.H
+ "h+" -> TelephonyIcons.H_PLUS
+ "lte" -> TelephonyIcons.LTE
+ "lte+" -> TelephonyIcons.LTE_PLUS
+ "dis" -> TelephonyIcons.DATA_DISABLED
+ "not" -> TelephonyIcons.NOT_DEFAULT_DATA
+ else -> TelephonyIcons.UNKNOWN
+ }
+
+@DataActivityType
+private fun String.toActivity(): Int =
+ when (this) {
+ "inout" -> DATA_ACTIVITY_INOUT
+ "in" -> DATA_ACTIVITY_IN
+ "out" -> DATA_ACTIVITY_OUT
+ else -> DATA_ACTIVITY_NONE
+ }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt
index 42171d0dc2b5..54162bb75f3e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt
@@ -25,11 +25,13 @@ import com.android.settingslib.SignalIcon
* Nullable fields represent optional command line arguments
*/
sealed interface FakeNetworkEventModel {
+ // Null means the default (chosen by the repository)
+ val subId: Int?
+
data class Mobile(
val level: Int?,
val dataType: SignalIcon.MobileIconGroup?,
- // Null means the default (chosen by the repository)
- val subId: Int?,
+ override val subId: Int?,
val carrierId: Int?,
val inflateStrength: Boolean = false,
@DataActivityType val activity: Int?,
@@ -40,8 +42,5 @@ sealed interface FakeNetworkEventModel {
val ntn: Boolean = false,
) : FakeNetworkEventModel
- data class MobileDisabled(
- // Null means the default (chosen by the repository)
- val subId: Int?
- ) : FakeNetworkEventModel
+ data class MobileDisabled(override val subId: Int?) : FakeNetworkEventModel
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..d61d11bcf6b7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepositoryKairos.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.telephony.CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager
+import android.util.Log
+import com.android.systemui.KairosBuilder
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.WifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import javax.inject.Inject
+
+/**
+ * A repository implementation for a carrier merged (aka VCN) network. A carrier merged network is
+ * delivered to SysUI as a wifi network (see [WifiNetworkModel.CarrierMerged], but is visually
+ * displayed as a mobile network triangle.
+ *
+ * See [android.net.wifi.WifiInfo.isCarrierMerged] for more information.
+ *
+ * See [MobileConnectionRepositoryImpl] for a repository implementation of a typical mobile
+ * connection.
+ */
+@ExperimentalKairosApi
+class CarrierMergedConnectionRepositoryKairos(
+ override val subId: Int,
+ override val tableLogBuffer: TableLogBuffer,
+ private val telephonyManager: TelephonyManager,
+ val wifiRepository: WifiRepository,
+ override val isInEcmMode: State<Boolean>,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+ init {
+ if (telephonyManager.subscriptionId != subId) {
+ error(
+ """CarrierMergedRepo: TelephonyManager should be created with subId($subId).
+ | Found ${telephonyManager.subscriptionId} instead."""
+ .trimMargin()
+ )
+ }
+ }
+
+ private val isWifiEnabled: State<Boolean> = buildState {
+ wifiRepository.isWifiEnabled.toState()
+ }
+ private val isWifiDefault: State<Boolean> = buildState {
+ wifiRepository.isWifiDefault.toState()
+ }
+ private val wifiNetwork: State<WifiNetworkModel> = buildState {
+ wifiRepository.wifiNetwork.toState()
+ }
+
+ /**
+ * Outputs the carrier merged network to use, or null if we don't have a valid carrier merged
+ * network.
+ */
+ private val network: State<WifiNetworkModel.CarrierMerged?> =
+ combine(isWifiEnabled, isWifiDefault, wifiNetwork) { isEnabled, isDefault, network ->
+ when {
+ !isEnabled -> null
+ !isDefault -> null
+ network !is WifiNetworkModel.CarrierMerged -> null
+ network.subscriptionId != subId -> {
+ Log.w(
+ TAG,
+ """Connection repo subId=$subId does not equal wifi repo
+ | subId=${network.subscriptionId}; not showing carrier merged"""
+ .trimMargin(),
+ )
+ null
+ }
+ else -> network
+ }
+ }
+
+ override val cdmaRoaming: State<Boolean> = stateOf(ROAMING)
+
+ override val networkName: State<NetworkNameModel> =
+ // The SIM operator name should be the same throughout the lifetime of a subId, **but**
+ // it may not be available when this repo is created because it takes time to load. To
+ // be safe, we re-fetch it each time the network has changed.
+ network.map { NetworkNameModel.SimDerived(telephonyManager.simOperatorName) }
+
+ override val carrierName: State<NetworkNameModel>
+ get() = networkName
+
+ override val numberOfLevels: State<Int> =
+ wifiNetwork.map {
+ if (it is WifiNetworkModel.CarrierMerged) {
+ it.numberOfLevels
+ } else {
+ DEFAULT_NUM_LEVELS
+ }
+ }
+
+ override val primaryLevel: State<Int> =
+ network.map { it?.level ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN }
+
+ override val cdmaLevel: State<Int> =
+ network.map { it?.level ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN }
+
+ override val dataActivityDirection: State<DataActivityModel> = buildState {
+ wifiRepository.wifiActivity.toState()
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> =
+ network.map {
+ if (it != null) {
+ ResolvedNetworkType.CarrierMergedNetworkType
+ } else {
+ ResolvedNetworkType.UnknownNetworkType
+ }
+ }
+
+ override val dataConnectionState: State<DataConnectionState> =
+ network.map {
+ if (it != null) {
+ DataConnectionState.Connected
+ } else {
+ DataConnectionState.Disconnected
+ }
+ }
+
+ override val isRoaming: State<Boolean> = stateOf(false)
+ override val carrierId: State<Int> = stateOf(INVALID_SUBSCRIPTION_ID)
+ override val inflateSignalStrength: State<Boolean> = stateOf(false)
+ override val allowNetworkSliceIndicator: State<Boolean> = stateOf(false)
+ override val isEmergencyOnly: State<Boolean> = stateOf(false)
+ override val operatorAlphaShort: State<String?> = stateOf(null)
+ override val isInService: State<Boolean> = stateOf(true)
+ override val isNonTerrestrial: State<Boolean> = stateOf(false)
+ override val isGsm: State<Boolean> = stateOf(false)
+ override val carrierNetworkChangeActive: State<Boolean> = stateOf(false)
+ override val satelliteLevel: State<Int> = stateOf(0)
+
+ /**
+ * Carrier merged connections happen over wifi but are displayed as a mobile triangle. Because
+ * they occur over wifi, it's possible to have a valid carrier merged connection even during
+ * airplane mode. See b/291993542.
+ */
+ override val isAllowedDuringAirplaneMode: State<Boolean> = stateOf(true)
+
+ /**
+ * It's not currently considered possible that a carrier merged network can have these
+ * prioritized capabilities. If we need to track them, we can add the same check as is in
+ * [MobileConnectionRepositoryImpl].
+ */
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> = stateOf(false)
+
+ override val dataEnabled: State<Boolean>
+ get() = isWifiEnabled
+
+ companion object {
+ // Carrier merged is never roaming
+ private const val ROAMING = false
+ }
+
+ @SysUISingleton
+ class Factory
+ @Inject
+ constructor(
+ private val telephonyManager: TelephonyManager,
+ private val wifiRepository: WifiRepository,
+ ) {
+ fun build(
+ subId: Int,
+ mobileLogger: TableLogBuffer,
+ mobileRepo: MobileConnectionRepositoryKairos,
+ ): CarrierMergedConnectionRepositoryKairos {
+ return CarrierMergedConnectionRepositoryKairos(
+ subId,
+ mobileLogger,
+ telephonyManager.createForSubscriptionId(subId),
+ wifiRepository,
+ mobileRepo.isInEcmMode,
+ )
+ }
+ }
+}
+
+private const val TAG = "CarrierMergedConnectionRepository"
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..1a8ca9577bd7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryKairos.kt
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.util.IndentingPrintWriter
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.KairosBuilder
+import com.android.systemui.kairos.BuildSpec
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.flatMap
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.io.PrintWriter
+
+/**
+ * A repository that fully implements a mobile connection.
+ *
+ * This connection could either be a typical mobile connection (see [MobileConnectionRepositoryImpl]
+ * or a carrier merged connection (see [CarrierMergedConnectionRepository]). This repository
+ * switches between the two types of connections based on whether the connection is currently
+ * carrier merged.
+ */
+@ExperimentalKairosApi
+class FullMobileConnectionRepositoryKairos
+@AssistedInject
+constructor(
+ @Assisted override val subId: Int,
+ @Assisted override val tableLogBuffer: TableLogBuffer,
+ @Assisted private val mobileRepo: MobileConnectionRepositoryKairos,
+ @Assisted private val carrierMergedRepoSpec: BuildSpec<MobileConnectionRepositoryKairos>,
+ @Assisted private val isCarrierMerged: State<Boolean>,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ init {
+ onActivated {
+ logDiffsForTable(isCarrierMerged, tableLogBuffer, columnName = "isCarrierMerged")
+ }
+ }
+
+ @VisibleForTesting
+ val activeRepo: State<MobileConnectionRepositoryKairos> = buildState {
+ isCarrierMerged.mapLatestBuild { merged ->
+ if (merged) {
+ carrierMergedRepoSpec.applySpec()
+ } else {
+ mobileRepo
+ }
+ }
+ }
+
+ override val carrierId: State<Int> = activeRepo.flatMap { it.carrierId }
+
+ override val cdmaRoaming: State<Boolean> = activeRepo.flatMap { it.cdmaRoaming }
+
+ override val isEmergencyOnly: State<Boolean> =
+ activeRepo
+ .flatMap { it.isEmergencyOnly }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_EMERGENCY) }
+ }
+
+ override val isRoaming: State<Boolean> =
+ activeRepo
+ .flatMap { it.isRoaming }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_ROAMING) } }
+
+ override val operatorAlphaShort: State<String?> =
+ activeRepo
+ .flatMap { it.operatorAlphaShort }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_OPERATOR) }
+ }
+
+ override val isInService: State<Boolean> =
+ activeRepo
+ .flatMap { it.isInService }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_IN_SERVICE) }
+ }
+
+ override val isNonTerrestrial: State<Boolean> =
+ activeRepo
+ .flatMap { it.isNonTerrestrial }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_NTN) } }
+
+ override val isGsm: State<Boolean> =
+ activeRepo
+ .flatMap { it.isGsm }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_IS_GSM) } }
+
+ override val cdmaLevel: State<Int> =
+ activeRepo
+ .flatMap { it.cdmaLevel }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_CDMA_LEVEL) }
+ }
+
+ override val primaryLevel: State<Int> =
+ activeRepo
+ .flatMap { it.primaryLevel }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = COL_PRIMARY_LEVEL) }
+ }
+
+ override val satelliteLevel: State<Int> =
+ activeRepo
+ .flatMap { it.satelliteLevel }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogBuffer, columnName = COL_SATELLITE_LEVEL)
+ }
+ }
+
+ override val dataConnectionState: State<DataConnectionState> =
+ activeRepo
+ .flatMap { it.dataConnectionState }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val dataActivityDirection: State<DataActivityModel> =
+ activeRepo
+ .flatMap { it.dataActivityDirection }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val carrierNetworkChangeActive: State<Boolean> =
+ activeRepo
+ .flatMap { it.carrierNetworkChangeActive }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogBuffer, columnName = COL_CARRIER_NETWORK_CHANGE)
+ }
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> =
+ activeRepo
+ .flatMap { it.resolvedNetworkType }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "") } }
+
+ override val dataEnabled: State<Boolean> =
+ activeRepo
+ .flatMap { it.dataEnabled }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = "dataEnabled") }
+ }
+
+ override val inflateSignalStrength: State<Boolean> =
+ activeRepo
+ .flatMap { it.inflateSignalStrength }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnName = "inflate") } }
+
+ override val allowNetworkSliceIndicator: State<Boolean> =
+ activeRepo
+ .flatMap { it.allowNetworkSliceIndicator }
+ .also {
+ onActivated { logDiffsForTable(it, tableLogBuffer, columnName = "allowSlice") }
+ }
+
+ override val numberOfLevels: State<Int> = activeRepo.flatMap { it.numberOfLevels }
+
+ override val networkName: State<NetworkNameModel> =
+ activeRepo
+ .flatMap { it.networkName }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "intent") } }
+
+ override val carrierName: State<NetworkNameModel> =
+ activeRepo
+ .flatMap { it.carrierName }
+ .also { onActivated { logDiffsForTable(it, tableLogBuffer, columnPrefix = "sub") } }
+
+ override val isAllowedDuringAirplaneMode: State<Boolean> =
+ activeRepo.flatMap { it.isAllowedDuringAirplaneMode }
+
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> =
+ activeRepo.flatMap { it.hasPrioritizedNetworkCapabilities }
+
+ override val isInEcmMode: State<Boolean> = activeRepo.flatMap { it.isInEcmMode }
+
+ private var dumpCache: DumpCache? = null
+
+ private data class DumpCache(
+ val isCarrierMerged: Boolean,
+ val activeRepo: MobileConnectionRepositoryKairos,
+ )
+
+ fun dump(pw: PrintWriter) {
+ val cache = dumpCache ?: return
+ val ipw = IndentingPrintWriter(pw, " ")
+
+ ipw.println("MobileConnectionRepository[$subId]")
+ ipw.increaseIndent()
+
+ ipw.println("carrierMerged=${cache.isCarrierMerged}")
+
+ ipw.print("Type (cellular or carrier merged): ")
+ when (cache.activeRepo) {
+ is CarrierMergedConnectionRepositoryKairos -> ipw.println("Carrier merged")
+ is MobileConnectionRepositoryKairosImpl -> ipw.println("Cellular")
+ }
+
+ ipw.increaseIndent()
+ ipw.println("Provider: ${cache.activeRepo}")
+ ipw.decreaseIndent()
+
+ ipw.decreaseIndent()
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ subId: Int,
+ mobileLogger: TableLogBuffer,
+ isCarrierMerged: State<Boolean>,
+ mobileRepo: MobileConnectionRepositoryKairos,
+ mergedRepoSpec: BuildSpec<MobileConnectionRepositoryKairos>,
+ ): FullMobileConnectionRepositoryKairos
+ }
+
+ companion object {
+ const val COL_CARRIER_ID = "carrierId"
+ const val COL_CARRIER_NETWORK_CHANGE = "carrierNetworkChangeActive"
+ const val COL_CDMA_LEVEL = "cdmaLevel"
+ const val COL_EMERGENCY = "emergencyOnly"
+ const val COL_IS_NTN = "isNtn"
+ const val COL_IS_GSM = "isGsm"
+ const val COL_IS_IN_SERVICE = "isInService"
+ const val COL_OPERATOR = "operatorName"
+ const val COL_PRIMARY_LEVEL = "primaryLevel"
+ const val COL_SATELLITE_LEVEL = "satelliteLevel"
+ const val COL_ROAMING = "roaming"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
index b4a45e24a0cb..bf7c2990b5ab 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
@@ -542,6 +542,10 @@ sealed interface CallbackEvent {
data class OnCarrierRoamingNtnSignalStrengthChanged(val signalStrength: NtnSignalStrength) :
CallbackEvent
+
+ data class OnCallBackModeStarted(val type: Int) : CallbackEvent
+
+ data class OnCallBackModeStopped(val type: Int) : CallbackEvent
}
/**
@@ -560,6 +564,8 @@ data class TelephonyCallbackState(
val onCarrierRoamingNtnSignalStrengthChanged:
CallbackEvent.OnCarrierRoamingNtnSignalStrengthChanged? =
null,
+ val addedCallbackModes: Set<Int> = emptySet(),
+ val removedCallbackModes: Set<Int> = emptySet(),
) {
fun applyEvent(event: CallbackEvent): TelephonyCallbackState {
return when (event) {
@@ -578,6 +584,37 @@ data class TelephonyCallbackState(
is CallbackEvent.OnSignalStrengthChanged -> copy(onSignalStrengthChanged = event)
is CallbackEvent.OnCarrierRoamingNtnSignalStrengthChanged ->
copy(onCarrierRoamingNtnSignalStrengthChanged = event)
+ is CallbackEvent.OnCallBackModeStarted -> {
+ copy(
+ addedCallbackModes =
+ if (event.type !in removedCallbackModes) {
+ addedCallbackModes + event.type
+ } else {
+ addedCallbackModes
+ },
+ removedCallbackModes =
+ if (event.type !in addedCallbackModes) {
+ removedCallbackModes - event.type
+ } else {
+ removedCallbackModes
+ },
+ )
+ }
+ is CallbackEvent.OnCallBackModeStopped ->
+ copy(
+ addedCallbackModes =
+ if (event.type !in removedCallbackModes) {
+ addedCallbackModes - event.type
+ } else {
+ addedCallbackModes
+ },
+ removedCallbackModes =
+ if (event.type !in addedCallbackModes) {
+ removedCallbackModes + event.type
+ } else {
+ removedCallbackModes
+ },
+ )
}
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt
new file mode 100644
index 000000000000..9b37f4896878
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosAdapter.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import com.android.systemui.kairos.BuildScope
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.util.kotlin.Producer
+import kotlinx.coroutines.flow.StateFlow
+
+@ExperimentalKairosApi
+fun BuildScope.MobileConnectionRepositoryKairosAdapter(
+ kairosRepo: MobileConnectionRepositoryKairos,
+ carrierConfig: SystemUiCarrierConfig,
+) =
+ MobileConnectionRepositoryKairosAdapter(
+ subId = kairosRepo.subId,
+ carrierId = kairosRepo.carrierId.toStateFlow(),
+ inflateSignalStrength = carrierConfig.shouldInflateSignalStrength,
+ allowNetworkSliceIndicator = carrierConfig.allowNetworkSliceIndicator,
+ tableLogBuffer = kairosRepo.tableLogBuffer,
+ isEmergencyOnly = kairosRepo.isEmergencyOnly.toStateFlow(),
+ isRoaming = kairosRepo.isRoaming.toStateFlow(),
+ operatorAlphaShort = kairosRepo.operatorAlphaShort.toStateFlow(),
+ isInService = kairosRepo.isInService.toStateFlow(),
+ isNonTerrestrial = kairosRepo.isNonTerrestrial.toStateFlow(),
+ isGsm = kairosRepo.isGsm.toStateFlow(),
+ cdmaLevel = kairosRepo.cdmaLevel.toStateFlow(),
+ primaryLevel = kairosRepo.primaryLevel.toStateFlow(),
+ satelliteLevel = kairosRepo.satelliteLevel.toStateFlow(),
+ dataConnectionState = kairosRepo.dataConnectionState.toStateFlow(),
+ dataActivityDirection = kairosRepo.dataActivityDirection.toStateFlow(),
+ carrierNetworkChangeActive = kairosRepo.carrierNetworkChangeActive.toStateFlow(),
+ resolvedNetworkType = kairosRepo.resolvedNetworkType.toStateFlow(),
+ numberOfLevels = kairosRepo.numberOfLevels.toStateFlow(),
+ dataEnabled = kairosRepo.dataEnabled.toStateFlow(),
+ cdmaRoaming = kairosRepo.cdmaRoaming.toStateFlow(),
+ networkName = kairosRepo.networkName.toStateFlow(),
+ carrierName = kairosRepo.carrierName.toStateFlow(),
+ isAllowedDuringAirplaneMode = kairosRepo.isAllowedDuringAirplaneMode.toStateFlow(),
+ hasPrioritizedNetworkCapabilities =
+ kairosRepo.hasPrioritizedNetworkCapabilities.toStateFlow(),
+ isInEcmMode = { kairosNetwork.transact { kairosRepo.isInEcmMode.sample() } },
+ )
+
+@ExperimentalKairosApi
+class MobileConnectionRepositoryKairosAdapter(
+ override val subId: Int,
+ override val carrierId: StateFlow<Int>,
+ override val inflateSignalStrength: StateFlow<Boolean>,
+ override val allowNetworkSliceIndicator: StateFlow<Boolean>,
+ override val tableLogBuffer: TableLogBuffer,
+ override val isEmergencyOnly: StateFlow<Boolean>,
+ override val isRoaming: StateFlow<Boolean>,
+ override val operatorAlphaShort: StateFlow<String?>,
+ override val isInService: StateFlow<Boolean>,
+ override val isNonTerrestrial: StateFlow<Boolean>,
+ override val isGsm: StateFlow<Boolean>,
+ override val cdmaLevel: StateFlow<Int>,
+ override val primaryLevel: StateFlow<Int>,
+ override val satelliteLevel: StateFlow<Int>,
+ override val dataConnectionState: StateFlow<DataConnectionState>,
+ override val dataActivityDirection: StateFlow<DataActivityModel>,
+ override val carrierNetworkChangeActive: StateFlow<Boolean>,
+ override val resolvedNetworkType: StateFlow<ResolvedNetworkType>,
+ override val numberOfLevels: StateFlow<Int>,
+ override val dataEnabled: StateFlow<Boolean>,
+ override val cdmaRoaming: StateFlow<Boolean>,
+ override val networkName: StateFlow<NetworkNameModel>,
+ override val carrierName: StateFlow<NetworkNameModel>,
+ override val isAllowedDuringAirplaneMode: StateFlow<Boolean>,
+ override val hasPrioritizedNetworkCapabilities: StateFlow<Boolean>,
+ private val isInEcmMode: Producer<Boolean>,
+) : MobileConnectionRepository {
+ override suspend fun isInEcmMode(): Boolean = isInEcmMode.get()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt
new file mode 100644
index 000000000000..abe72e17163b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryKairosImpl.kt
@@ -0,0 +1,485 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.telephony.CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN
+import android.telephony.CellSignalStrengthCdma
+import android.telephony.ServiceState
+import android.telephony.SignalStrength
+import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyDisplayInfo
+import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.ERI_FLASH
+import android.telephony.TelephonyManager.ERI_ON
+import android.telephony.TelephonyManager.EXTRA_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN
+import android.telephony.TelephonyManager.UNKNOWN_CARRIER_ID
+import com.android.settingslib.Utils
+import com.android.systemui.KairosBuilder
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.flags.FeatureFlagsClassic
+import com.android.systemui.flags.Flags.ROAMING_INDICATOR_VIA_DISPLAY_INFO
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.Transactional
+import com.android.systemui.kairos.awaitClose
+import com.android.systemui.kairos.coalescingEvents
+import com.android.systemui.kairos.conflatedEvents
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapNotNull
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairos.transactionally
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState.Disconnected
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.toDataConnectionType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.toNetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.toMobileDataActivityModel
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.time.Duration
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.withContext
+
+/**
+ * A repository implementation for a typical mobile connection (as opposed to a carrier merged
+ * connection -- see [CarrierMergedConnectionRepository]).
+ */
+@ExperimentalKairosApi
+class MobileConnectionRepositoryKairosImpl
+@AssistedInject
+constructor(
+ @Assisted override val subId: Int,
+ private val context: Context,
+ @Assisted subscriptionModel: State<SubscriptionModel?>,
+ @Assisted defaultNetworkName: NetworkNameModel,
+ @Assisted networkNameSeparator: String,
+ connectivityManager: ConnectivityManager,
+ @Assisted private val telephonyManager: TelephonyManager,
+ @Assisted systemUiCarrierConfig: SystemUiCarrierConfig,
+ broadcastDispatcher: BroadcastDispatcher,
+ private val mobileMappingsProxy: MobileMappingsProxy,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ logger: MobileInputLogger,
+ @Assisted override val tableLogBuffer: TableLogBuffer,
+ flags: FeatureFlagsClassic,
+) : MobileConnectionRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ init {
+ if (telephonyManager.subscriptionId != subId) {
+ throw IllegalStateException(
+ "MobileRepo: TelephonyManager should be created with subId($subId). " +
+ "Found ${telephonyManager.subscriptionId} instead."
+ )
+ }
+ }
+
+ /**
+ * This flow defines the single shared connection to system_server via TelephonyCallback. Any
+ * new callback should be added to this listener and funneled through callbackEvents via a data
+ * class. See [CallbackEvent] for defining new callbacks.
+ *
+ * The reason we need to do this is because TelephonyManager limits the number of registered
+ * listeners per-process, so we don't want to create a new listener for every callback.
+ *
+ * A note on the design for back pressure here: We don't control _which_ telephony callback
+ * comes in first, since we register every relevant bit of information as a batch. E.g., if a
+ * downstream starts collecting on a field which is backed by
+ * [TelephonyCallback.ServiceStateListener], it's not possible for us to guarantee that _that_
+ * callback comes in -- the first callback could very well be
+ * [TelephonyCallback.DataActivityListener], which would promptly be dropped if we didn't keep
+ * it tracked. We use the [scan] operator here to track the most recent callback of _each type_
+ * here. See [TelephonyCallbackState] to see how the callbacks are stored.
+ */
+ private val callbackEvents: Events<TelephonyCallbackState> = buildEvents {
+ coalescingEvents(
+ initialValue = TelephonyCallbackState(),
+ coalesce = TelephonyCallbackState::applyEvent,
+ ) {
+ val callback =
+ object :
+ TelephonyCallback(),
+ TelephonyCallback.CarrierNetworkListener,
+ TelephonyCallback.CarrierRoamingNtnListener,
+ TelephonyCallback.DataActivityListener,
+ TelephonyCallback.DataConnectionStateListener,
+ TelephonyCallback.DataEnabledListener,
+ TelephonyCallback.DisplayInfoListener,
+ TelephonyCallback.ServiceStateListener,
+ TelephonyCallback.SignalStrengthsListener,
+ TelephonyCallback.EmergencyCallbackModeListener {
+
+ override fun onCarrierNetworkChange(active: Boolean) {
+ logger.logOnCarrierNetworkChange(active, subId)
+ emit(CallbackEvent.OnCarrierNetworkChange(active))
+ }
+
+ override fun onCarrierRoamingNtnModeChanged(active: Boolean) {
+ logger.logOnCarrierRoamingNtnModeChanged(active)
+ emit(CallbackEvent.OnCarrierRoamingNtnModeChanged(active))
+ }
+
+ override fun onDataActivity(direction: Int) {
+ logger.logOnDataActivity(direction, subId)
+ emit(CallbackEvent.OnDataActivity(direction))
+ }
+
+ override fun onDataEnabledChanged(enabled: Boolean, reason: Int) {
+ logger.logOnDataEnabledChanged(enabled, subId)
+ emit(CallbackEvent.OnDataEnabledChanged(enabled))
+ }
+
+ override fun onDataConnectionStateChanged(dataState: Int, networkType: Int) {
+ logger.logOnDataConnectionStateChanged(dataState, networkType, subId)
+ emit(CallbackEvent.OnDataConnectionStateChanged(dataState))
+ }
+
+ override fun onDisplayInfoChanged(telephonyDisplayInfo: TelephonyDisplayInfo) {
+ logger.logOnDisplayInfoChanged(telephonyDisplayInfo, subId)
+ emit(CallbackEvent.OnDisplayInfoChanged(telephonyDisplayInfo))
+ }
+
+ override fun onServiceStateChanged(serviceState: ServiceState) {
+ logger.logOnServiceStateChanged(serviceState, subId)
+ emit(CallbackEvent.OnServiceStateChanged(serviceState))
+ }
+
+ override fun onSignalStrengthsChanged(signalStrength: SignalStrength) {
+ logger.logOnSignalStrengthsChanged(signalStrength, subId)
+ emit(CallbackEvent.OnSignalStrengthChanged(signalStrength))
+ }
+
+ override fun onCallbackModeStarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ // logger.logOnCallBackModeStarted(type, subId)
+ emit(CallbackEvent.OnCallBackModeStarted(type))
+ }
+
+ override fun onCallbackModeRestarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ // no-op
+ }
+
+ override fun onCallbackModeStopped(type: Int, reason: Int, subId: Int) {
+ // logger.logOnCallBackModeStopped(type, reason, subId)
+ emit(CallbackEvent.OnCallBackModeStopped(type))
+ }
+ }
+ withContext(bgDispatcher) {
+ telephonyManager.registerTelephonyCallback(bgDispatcher.asExecutor(), callback)
+ }
+ awaitClose { telephonyManager.unregisterTelephonyCallback(callback) }
+ }
+ }
+
+ private val serviceState: State<ServiceState?> = buildState {
+ callbackEvents.mapNotNull { it.onServiceStateChanged?.serviceState }.holdState(null)
+ }
+
+ override val isEmergencyOnly: State<Boolean> = serviceState.map { it?.isEmergencyOnly == true }
+
+ private val displayInfo: State<TelephonyDisplayInfo?> = buildState {
+ callbackEvents.mapNotNull { it.onDisplayInfoChanged?.telephonyDisplayInfo }.holdState(null)
+ }
+
+ override val isRoaming: State<Boolean> =
+ if (flags.isEnabled(ROAMING_INDICATOR_VIA_DISPLAY_INFO)) {
+ displayInfo.map { it?.isRoaming == true }
+ } else {
+ serviceState.map { it?.roaming == true }
+ }
+
+ override val operatorAlphaShort: State<String?> = serviceState.map { it?.operatorAlphaShort }
+
+ override val isInService: State<Boolean> =
+ serviceState.map { it?.let(Utils::isInService) == true }
+
+ private val carrierRoamingNtnActive: State<Boolean> = buildState {
+ callbackEvents.mapNotNull { it.onCarrierRoamingNtnModeChanged?.active }.holdState(false)
+ }
+
+ override val isNonTerrestrial: State<Boolean>
+ get() = carrierRoamingNtnActive
+
+ private val signalStrength: State<SignalStrength?> = buildState {
+ callbackEvents.mapNotNull { it.onSignalStrengthChanged?.signalStrength }.holdState(null)
+ }
+
+ override val isGsm: State<Boolean> = signalStrength.map { it?.isGsm == true }
+
+ override val cdmaLevel: State<Int> =
+ signalStrength.map {
+ it?.getCellSignalStrengths(CellSignalStrengthCdma::class.java)?.firstOrNull()?.level
+ ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN
+ }
+
+ override val primaryLevel: State<Int> =
+ signalStrength.map { it?.level ?: SIGNAL_STRENGTH_NONE_OR_UNKNOWN }
+
+ override val satelliteLevel: State<Int> = buildState {
+ callbackEvents
+ .mapNotNull { it.onCarrierRoamingNtnSignalStrengthChanged?.signalStrength?.level }
+ .holdState(0)
+ }
+
+ override val dataConnectionState: State<DataConnectionState> = buildState {
+ callbackEvents
+ .mapNotNull { it.onDataConnectionStateChanged?.dataState?.toDataConnectionType() }
+ .holdState(Disconnected)
+ }
+
+ override val dataActivityDirection: State<DataActivityModel> = buildState {
+ callbackEvents
+ .mapNotNull { it.onDataActivity?.direction?.toMobileDataActivityModel() }
+ .holdState(DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+ }
+
+ override val carrierNetworkChangeActive: State<Boolean> = buildState {
+ callbackEvents.mapNotNull { it.onCarrierNetworkChange?.active }.holdState(false)
+ }
+
+ private val telephonyDisplayInfo: State<TelephonyDisplayInfo?> = buildState {
+ callbackEvents.mapNotNull { it.onDisplayInfoChanged?.telephonyDisplayInfo }.holdState(null)
+ }
+
+ override val resolvedNetworkType: State<ResolvedNetworkType> =
+ telephonyDisplayInfo.map { displayInfo ->
+ displayInfo
+ ?.overrideNetworkType
+ ?.takeIf { it != OVERRIDE_NETWORK_TYPE_NONE }
+ ?.let { OverrideNetworkType(mobileMappingsProxy.toIconKeyOverride(it)) }
+ ?: displayInfo
+ ?.networkType
+ ?.takeIf { it != NETWORK_TYPE_UNKNOWN }
+ ?.let { DefaultNetworkType(mobileMappingsProxy.toIconKey(it)) }
+ ?: UnknownNetworkType
+ }
+
+ override val inflateSignalStrength: State<Boolean> = buildState {
+ systemUiCarrierConfig.shouldInflateSignalStrength.toState()
+ }
+
+ override val allowNetworkSliceIndicator: State<Boolean> = buildState {
+ systemUiCarrierConfig.allowNetworkSliceIndicator.toState()
+ }
+
+ override val numberOfLevels: State<Int> =
+ inflateSignalStrength.map { shouldInflate ->
+ if (shouldInflate) {
+ DEFAULT_NUM_LEVELS + 1
+ } else {
+ DEFAULT_NUM_LEVELS
+ }
+ }
+
+ override val carrierName: State<NetworkNameModel> =
+ subscriptionModel.map {
+ it?.let { model -> NetworkNameModel.SubscriptionDerived(model.carrierName) }
+ ?: defaultNetworkName
+ }
+
+ /**
+ * There are a few cases where we will need to poll [TelephonyManager] so we can update some
+ * internal state where callbacks aren't provided. Any of those events should be merged into
+ * this flow, which can be used to trigger the polling.
+ */
+ private val telephonyPollingEvent: Events<Unit> = callbackEvents.map {}
+
+ private val cdmaEnhancedRoamingIndicatorDisplayNumber: Transactional<Int?> = transactionally {
+ try {
+ telephonyManager.cdmaEnhancedRoamingIndicatorDisplayNumber
+ } catch (e: UnsupportedOperationException) {
+ // Handles the same as a function call failure
+ null
+ }
+ }
+
+ override val cdmaRoaming: State<Boolean> = buildState {
+ telephonyPollingEvent
+ .map {
+ val cdmaEri = cdmaEnhancedRoamingIndicatorDisplayNumber.sample()
+ cdmaEri == ERI_ON || cdmaEri == ERI_FLASH
+ }
+ .holdState(false)
+ }
+
+ override val carrierId: State<Int> = buildState {
+ broadcastDispatcher
+ .broadcastFlow(
+ filter =
+ IntentFilter(TelephonyManager.ACTION_SUBSCRIPTION_CARRIER_IDENTITY_CHANGED),
+ map = { intent, _ -> intent },
+ )
+ .filter { intent ->
+ intent.getIntExtra(EXTRA_SUBSCRIPTION_ID, INVALID_SUBSCRIPTION_ID) == subId
+ }
+ .map { it.carrierId() }
+ .toState(telephonyManager.simCarrierId)
+ }
+
+ /**
+ * BroadcastDispatcher does not handle sticky broadcasts, so we can't use it here. Note that we
+ * now use the [SharingStarted.Eagerly] strategy, because there have been cases where the sticky
+ * broadcast does not represent the correct state.
+ *
+ * See b/322432056 for context.
+ */
+ @SuppressLint("RegisterReceiverViaContext")
+ override val networkName: State<NetworkNameModel> = buildState {
+ conflatedEvents {
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (
+ intent.getIntExtra(
+ EXTRA_SUBSCRIPTION_INDEX,
+ INVALID_SUBSCRIPTION_ID,
+ ) == subId
+ ) {
+ logger.logServiceProvidersUpdatedBroadcast(intent)
+ emit(
+ intent.toNetworkNameModel(networkNameSeparator)
+ ?: defaultNetworkName
+ )
+ }
+ }
+ }
+
+ context.registerReceiver(
+ receiver,
+ IntentFilter(TelephonyManager.ACTION_SERVICE_PROVIDERS_UPDATED),
+ )
+
+ awaitClose { context.unregisterReceiver(receiver) }
+ }
+ .holdState(defaultNetworkName)
+ }
+
+ override val dataEnabled: State<Boolean> = buildState {
+ callbackEvents
+ .mapNotNull { it.onDataEnabledChanged?.enabled }
+ .holdState(telephonyManager.isDataConnectionAllowed)
+ }
+
+ override val isInEcmMode: State<Boolean> = buildState {
+ callbackEvents
+ .mapNotNull {
+ (it.addedCallbackModes to it.removedCallbackModes).takeIf { (added, removed) ->
+ added.isNotEmpty() || removed.isNotEmpty()
+ }
+ }
+ .foldState(emptySet<Int>()) { (added, removed), acc -> acc - removed + added }
+ .mapTransactionally { it.isNotEmpty() }
+ }
+
+ /** Typical mobile connections aren't available during airplane mode. */
+ override val isAllowedDuringAirplaneMode: State<Boolean> = stateOf(false)
+
+ /**
+ * Currently, a network with NET_CAPABILITY_PRIORITIZE_LATENCY is the only type of network that
+ * we consider to be a "network slice". _PRIORITIZE_BANDWIDTH may be added in the future. Any of
+ * these capabilities that are used here must also be represented in the
+ * self_certified_network_capabilities.xml config file
+ */
+ @SuppressLint("WrongConstant")
+ private val networkSliceRequest: NetworkRequest =
+ NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY)
+ .setSubscriptionIds(setOf(subId))
+ .build()
+
+ @SuppressLint("MissingPermission")
+ override val hasPrioritizedNetworkCapabilities: State<Boolean> = buildState {
+ conflatedEvents {
+ // Our network callback listens only for this.subId && net_cap_prioritize_latency
+ // therefore our state is a simple mapping of whether or not that network exists
+ val callback =
+ object : NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ logger.logPrioritizedNetworkAvailable(network.netId)
+ emit(true)
+ }
+
+ override fun onLost(network: Network) {
+ logger.logPrioritizedNetworkLost(network.netId)
+ emit(false)
+ }
+ }
+
+ connectivityManager.registerNetworkCallback(networkSliceRequest, callback)
+
+ awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
+ }
+ .holdState(false)
+ }
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(
+ subId: Int,
+ mobileLogger: TableLogBuffer,
+ subscriptionModel: State<SubscriptionModel?>,
+ defaultNetworkName: NetworkNameModel,
+ networkNameSeparator: String,
+ systemUiCarrierConfig: SystemUiCarrierConfig,
+ telephonyManager: TelephonyManager,
+ ): MobileConnectionRepositoryKairosImpl
+ }
+}
+
+private fun Intent.carrierId(): Int =
+ getIntExtra(TelephonyManager.EXTRA_CARRIER_ID, UNKNOWN_CARRIER_ID)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt
new file mode 100644
index 000000000000..e46815954e64
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryKairosImpl.kt
@@ -0,0 +1,584 @@
+/*
+ * Copyright (C) 2024 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.data.repository.prod
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.telephony.CarrierConfigManager
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyCallback.ActiveDataSubscriptionIdListener
+import android.telephony.TelephonyCallback.EmergencyCallbackModeListener
+import android.telephony.TelephonyManager
+import android.util.IndentingPrintWriter
+import com.android.internal.telephony.PhoneConstants
+import com.android.keyguard.KeyguardUpdateMonitor
+import com.android.keyguard.KeyguardUpdateMonitorCallback
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.mobile.MobileMappings.Config
+import com.android.systemui.Dumpable
+import com.android.systemui.Flags
+import com.android.systemui.KairosActivatable
+import com.android.systemui.KairosBuilder
+import com.android.systemui.activated
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.kairos.BuildSpec
+import com.android.systemui.kairos.Events
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.Incremental
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.StateSelector
+import com.android.systemui.kairos.asIncremental
+import com.android.systemui.kairos.asyncEvent
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.changes
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.effect
+import com.android.systemui.kairos.filterNotNull
+import com.android.systemui.kairos.flatMap
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapNotNull
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairos.mergeLeft
+import com.android.systemui.kairos.onEach
+import com.android.systemui.kairos.rebuildOn
+import com.android.systemui.kairos.selector
+import com.android.systemui.kairos.stateOf
+import com.android.systemui.kairos.switchEvents
+import com.android.systemui.kairos.transitions
+import com.android.systemui.kairos.util.WithPrev
+import com.android.systemui.kairosBuilder
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.TableLogBufferFactory
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.pipeline.airplane.data.repository.AirplaneModeRepository
+import com.android.systemui.statusbar.pipeline.dagger.MobileSummaryLog
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.CarrierConfigRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.SubscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.shared.data.model.DefaultConnectionModel
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.WifiRepository
+import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
+import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
+import dagger.Binds
+import dagger.Lazy
+import dagger.Provides
+import dagger.multibindings.ElementsIntoSet
+import java.io.PrintWriter
+import java.time.Duration
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.withContext
+
+@ExperimentalKairosApi
+@SysUISingleton
+class MobileConnectionsRepositoryKairosImpl
+@Inject
+constructor(
+ connectivityRepository: ConnectivityRepository,
+ private val subscriptionManager: SubscriptionManager,
+ private val subscriptionManagerProxy: SubscriptionManagerProxy,
+ private val telephonyManager: TelephonyManager,
+ private val logger: MobileInputLogger,
+ @MobileSummaryLog private val tableLogger: TableLogBuffer,
+ mobileMappingsProxy: MobileMappingsProxy,
+ broadcastDispatcher: BroadcastDispatcher,
+ private val context: Context,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ @Main private val mainDispatcher: CoroutineDispatcher,
+ airplaneModeRepository: AirplaneModeRepository,
+ // Some "wifi networks" should be rendered as a mobile connection, which is why the wifi
+ // repository is an input to the mobile repository.
+ // See [CarrierMergedConnectionRepositoryKairos] for details.
+ wifiRepository: WifiRepository,
+ private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
+ dumpManager: DumpManager,
+ private val mobileRepoFactory: Lazy<ConnectionRepoFactory>,
+) : MobileConnectionsRepositoryKairos, Dumpable, KairosBuilder by kairosBuilder() {
+
+ init {
+ dumpManager.registerNormalDumpable("MobileConnectionsRepositoryKairos", this)
+ }
+
+ private val carrierMergedSubId: State<Int?> = buildState {
+ combine(
+ wifiRepository.wifiNetwork.toState(),
+ connectivityRepository.defaultConnections.toState(),
+ airplaneModeRepository.isAirplaneMode.toState(),
+ ) { wifiNetwork, defaultConnections, isAirplaneMode ->
+ // The carrier merged connection should only be used if it's also the default
+ // connection or mobile connections aren't available because of airplane mode.
+ val defaultConnectionIsNonMobile =
+ defaultConnections.carrierMerged.isDefault ||
+ defaultConnections.wifi.isDefault ||
+ isAirplaneMode
+
+ if (wifiNetwork is WifiNetworkModel.CarrierMerged && defaultConnectionIsNonMobile) {
+ wifiNetwork.subscriptionId
+ } else {
+ null
+ }
+ }
+ .also {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "carrierMergedSubId")
+ }
+ }
+
+ private val mobileSubscriptionsChangeEvent: Events<Unit> = buildEvents {
+ conflatedCallbackFlow {
+ val callback =
+ object : SubscriptionManager.OnSubscriptionsChangedListener() {
+ override fun onSubscriptionsChanged() {
+ logger.logOnSubscriptionsChanged()
+ trySend(Unit)
+ }
+ }
+ subscriptionManager.addOnSubscriptionsChangedListener(Runnable::run, callback)
+ awaitClose { subscriptionManager.removeOnSubscriptionsChangedListener(callback) }
+ }
+ .flowOn(bgDispatcher)
+ .toEvents()
+ }
+
+ /** Turn ACTION_SERVICE_STATE (for subId = -1) into an event */
+ private val serviceStateChangedEvent: Events<Unit> = buildEvents {
+ broadcastDispatcher
+ .broadcastFlow(IntentFilter(Intent.ACTION_SERVICE_STATE)) { intent, _ ->
+ val subId =
+ intent.getIntExtra(
+ SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX,
+ INVALID_SUBSCRIPTION_ID,
+ )
+
+ // Only emit if the subId is not associated with an active subscription
+ if (subId == INVALID_SUBSCRIPTION_ID) {
+ Unit
+ }
+ }
+ .toEvents()
+ }
+
+ /** Eager flow to determine the device-based emergency calls only state */
+ override val isDeviceEmergencyCallCapable: State<Boolean> = buildState {
+ rebuildOn(serviceStateChangedEvent) { asyncEvent { doAnyModemsSupportEmergencyCalls() } }
+ .switchEvents()
+ .holdState(false)
+ .also {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "deviceEmergencyOnly",
+ )
+ }
+ }
+
+ private suspend fun doAnyModemsSupportEmergencyCalls(): Boolean =
+ withContext(bgDispatcher) {
+ val modems = telephonyManager.activeModemCount
+
+ // Assume false for automotive devices which don't have the calling feature.
+ // TODO: b/398045526 to revisit the below.
+ val isAutomotive: Boolean =
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
+ val hasFeatureCalling: Boolean =
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_CALLING)
+ if (isAutomotive && !hasFeatureCalling) {
+ return@withContext false
+ }
+
+ // Check the service state for every modem. If any state reports emergency calling
+ // capable, then consider the device to have emergency call capabilities
+ (0..<modems)
+ .map { telephonyManager.getServiceStateForSlot(it) }
+ .any { it?.isEmergencyOnly == true }
+ }
+
+ /**
+ * State flow that emits the set of mobile data subscriptions, each represented by its own
+ * [SubscriptionModel].
+ */
+ override val subscriptions: State<List<SubscriptionModel>> = buildState {
+ rebuildOn(mergeLeft(mobileSubscriptionsChangeEvent, carrierMergedSubId.changes)) {
+ asyncEvent { fetchSubscriptionModels() }
+ }
+ .switchEvents()
+ .holdState(emptyList())
+ .also {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "subscriptions")
+ }
+ }
+
+ val subscriptionsById: State<Map<Int, SubscriptionModel>> =
+ subscriptions.map { subs -> subs.associateBy { it.subscriptionId } }
+
+ override val mobileConnectionsBySubId: Incremental<Int, MobileConnectionRepositoryKairos> =
+ buildIncremental {
+ subscriptionsById
+ .asIncremental()
+ .mapValues { (subId, sub) -> mobileRepoFactory.get().create(subId) }
+ .applyLatestSpecForKey()
+ }
+
+ private val telephonyManagerState: State<Pair<Int?, Set<Int>>> = buildState {
+ callbackFlow {
+ val callback =
+ object :
+ TelephonyCallback(),
+ ActiveDataSubscriptionIdListener,
+ EmergencyCallbackModeListener {
+ override fun onActiveDataSubscriptionIdChanged(subId: Int) {
+ if (subId != INVALID_SUBSCRIPTION_ID) {
+ trySend { (_, set): Pair<Int?, Set<Int>> -> subId to set }
+ } else {
+ trySend { (_, set): Pair<Int?, Set<Int>> -> null to set }
+ }
+ }
+
+ override fun onCallbackModeStarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ trySend { (id, set): Pair<Int?, Set<Int>> -> id to (set + type) }
+ }
+
+ override fun onCallbackModeRestarted(
+ type: Int,
+ timerDuration: Duration,
+ subId: Int,
+ ) {
+ // no-op
+ }
+
+ override fun onCallbackModeStopped(type: Int, reason: Int, subId: Int) {
+ trySend { (id, set): Pair<Int?, Set<Int>> -> id to (set - type) }
+ }
+ }
+ telephonyManager.registerTelephonyCallback(Runnable::run, callback)
+ awaitClose { telephonyManager.unregisterTelephonyCallback(callback) }
+ }
+ .flowOn(bgDispatcher)
+ .scanToState(null to emptySet())
+ }
+
+ override val activeMobileDataSubscriptionId: State<Int?> =
+ telephonyManagerState
+ .map { it.first }
+ .also {
+ onActivated {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "activeSubId")
+ }
+ }
+
+ override val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?> =
+ combine(activeMobileDataSubscriptionId, mobileConnectionsBySubId) { id, cache -> cache[id] }
+
+ override val defaultDataSubId: State<Int?> = buildState {
+ broadcastDispatcher
+ .broadcastFlow(
+ IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
+ ) { intent, _ ->
+ intent
+ .getIntExtra(PhoneConstants.SUBSCRIPTION_KEY, INVALID_SUBSCRIPTION_ID)
+ .takeIf { it != INVALID_SUBSCRIPTION_ID }
+ }
+ .onStart {
+ emit(
+ subscriptionManagerProxy.getDefaultDataSubscriptionId().takeIf {
+ it != INVALID_SUBSCRIPTION_ID
+ }
+ )
+ }
+ .toState(initialValue = null)
+ .also { logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "defaultSubId") }
+ }
+
+ private val carrierConfigChangedEvent: Events<Unit> =
+ buildEvents {
+ broadcastDispatcher
+ .broadcastFlow(IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED))
+ .toEvents()
+ }
+ .onEach { logger.logActionCarrierConfigChanged() }
+
+ override val defaultDataSubRatConfig: State<Config> = buildState {
+ rebuildOn(mergeLeft(defaultDataSubId.changes, carrierConfigChangedEvent)) {
+ Config.readConfig(context).also { effect { logger.logDefaultDataSubRatConfig(it) } }
+ }
+ }
+
+ override val defaultMobileIconMapping: State<Map<String, MobileIconGroup>> = buildState {
+ defaultDataSubRatConfig
+ .map { mobileMappingsProxy.mapIconSets(it) }
+ .apply { observe { logger.logDefaultMobileIconMapping(it) } }
+ }
+
+ override val defaultMobileIconGroup: State<MobileIconGroup> = buildState {
+ defaultDataSubRatConfig
+ .map { mobileMappingsProxy.getDefaultIcons(it) }
+ .apply { observe { logger.logDefaultMobileIconGroup(it) } }
+ }
+
+ override val isAnySimSecure: State<Boolean> = buildState {
+ conflatedCallbackFlow {
+ val callback =
+ object : KeyguardUpdateMonitorCallback() {
+ override fun onSimStateChanged(subId: Int, slotId: Int, simState: Int) {
+ logger.logOnSimStateChanged()
+ trySend(keyguardUpdateMonitor.isSimPinSecure)
+ }
+ }
+ keyguardUpdateMonitor.registerCallback(callback)
+ awaitClose { keyguardUpdateMonitor.removeCallback(callback) }
+ }
+ .flowOn(mainDispatcher)
+ .toState(false)
+ .also {
+ logDiffsForTable(it, tableLogger, LOGGING_PREFIX, columnName = "isAnySimSecure")
+ }
+ }
+
+ private val defaultConnections: State<DefaultConnectionModel> = buildState {
+ connectivityRepository.defaultConnections.toState()
+ }
+
+ override val mobileIsDefault: State<Boolean> =
+ defaultConnections
+ .map { it.mobile.isDefault }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "mobileIsDefault",
+ )
+ }
+ }
+
+ override val hasCarrierMergedConnection: State<Boolean> =
+ carrierMergedSubId
+ .map { it != null }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "hasCarrierMergedConnection",
+ )
+ }
+ }
+
+ override val defaultConnectionIsValidated: State<Boolean> =
+ defaultConnections
+ .map { it.isValidated }
+ .also {
+ onActivated {
+ logDiffsForTable(
+ it,
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "defaultConnectionIsValidated",
+ )
+ }
+ }
+
+ /**
+ * Flow that tracks the active mobile data subscriptions. Emits `true` whenever the active data
+ * subscription Id changes but the subscription group remains the same. In these cases, we want
+ * to retain the previous subscription's validation status for up to 2s to avoid flickering the
+ * icon.
+ *
+ * TODO(b/265164432): we should probably expose all change events, not just same group
+ */
+ @SuppressLint("MissingPermission")
+ override val activeSubChangedInGroupEvent: Events<Unit> = buildEvents {
+ activeMobileDataSubscriptionId.transitions
+ .mapNotNull { (prevVal, newVal) ->
+ prevVal?.let { newVal?.let { WithPrev(prevVal, newVal) } }
+ }
+ .mapAsyncLatest { (prevVal, newVal) ->
+ if (isActiveSubChangeInGroup(prevVal, newVal)) Unit else null
+ }
+ .filterNotNull()
+ }
+
+ private suspend fun isActiveSubChangeInGroup(prevId: Int, newId: Int): Boolean =
+ withContext(bgDispatcher) {
+ val prevSub = subscriptionManager.getActiveSubscriptionInfo(prevId)?.groupUuid
+ val nextSub = subscriptionManager.getActiveSubscriptionInfo(newId)?.groupUuid
+ prevSub != null && prevSub == nextSub
+ }
+
+ private val isInEcmModeTopLevel: State<Boolean> =
+ telephonyManagerState.map { it.second.isNotEmpty() }
+
+ override val isInEcmMode: State<Boolean> =
+ isInEcmModeTopLevel.flatMap { isInEcm ->
+ if (isInEcm) {
+ stateOf(true)
+ } else {
+ mobileConnectionsBySubId.flatMap {
+ it.mapValues { it.value.isInEcmMode }.combine().map { it.values.any { it } }
+ }
+ }
+ }
+
+ /** Determines which subId is currently carrier-merged. */
+ val carrierMergedSelector: StateSelector<Int?> = carrierMergedSubId.selector()
+
+ private suspend fun fetchSubscriptionModels(): List<SubscriptionModel> =
+ withContext(bgDispatcher) {
+ subscriptionManager.completeActiveSubscriptionInfoList.map { it.toSubscriptionModel() }
+ }
+
+ private fun SubscriptionInfo.toSubscriptionModel(): SubscriptionModel =
+ SubscriptionModel(
+ subscriptionId = subscriptionId,
+ isOpportunistic = isOpportunistic,
+ isExclusivelyNonTerrestrial = isOnlyNonTerrestrialNetwork,
+ groupUuid = groupUuid,
+ carrierName = carrierName.toString(),
+ profileClass = profileClass,
+ )
+
+ private var dumpCache: DumpCache? = null
+
+ private data class DumpCache(val repos: Map<Int, FullMobileConnectionRepositoryKairos>)
+
+ override fun dump(pw: PrintWriter, args: Array<String>) {
+ val cache = dumpCache ?: return
+ val ipw = IndentingPrintWriter(pw, " ")
+ ipw.println("Connection cache:")
+
+ ipw.increaseIndent()
+ cache.repos.forEach { (subId, repo) -> ipw.println("$subId: $repo") }
+ ipw.decreaseIndent()
+
+ ipw.println("Connections (${cache.repos.size} total):")
+ ipw.increaseIndent()
+ cache.repos.values.forEach { it.dump(ipw) }
+ ipw.decreaseIndent()
+ }
+
+ fun interface ConnectionRepoFactory {
+ fun create(subId: Int): BuildSpec<MobileConnectionRepositoryKairos>
+ }
+
+ @dagger.Module
+ object Module {
+ @Provides
+ @ElementsIntoSet
+ fun kairosActivatable(
+ impl: Provider<MobileConnectionsRepositoryKairosImpl>
+ ): Set<@JvmSuppressWildcards KairosActivatable> =
+ if (Flags.statusBarMobileIconKairos()) setOf(impl.get()) else emptySet()
+ }
+
+ companion object {
+ private const val LOGGING_PREFIX = "Repo"
+ }
+}
+
+@ExperimentalKairosApi
+class MobileConnectionRepositoryKairosFactoryImpl
+@Inject
+constructor(
+ context: Context,
+ private val connectionsRepo: MobileConnectionsRepositoryKairosImpl,
+ private val logFactory: TableLogBufferFactory,
+ private val carrierConfigRepo: CarrierConfigRepository,
+ private val telephonyManager: TelephonyManager,
+ private val mobileRepoFactory: MobileConnectionRepositoryKairosImpl.Factory,
+ private val mergedRepoFactory: CarrierMergedConnectionRepositoryKairos.Factory,
+) : MobileConnectionsRepositoryKairosImpl.ConnectionRepoFactory {
+
+ private val networkNameSeparator: String =
+ context.getString(R.string.status_bar_network_name_separator)
+
+ private val defaultNetworkName =
+ NetworkNameModel.Default(
+ context.getString(com.android.internal.R.string.lockscreen_carrier_default)
+ )
+
+ override fun create(subId: Int): BuildSpec<MobileConnectionRepositoryKairos> = buildSpec {
+ activated {
+ val mobileLogger =
+ logFactory.getOrCreate(tableBufferLogName(subId), MOBILE_CONNECTION_BUFFER_SIZE)
+ val mobileRepo = activated {
+ mobileRepoFactory.create(
+ subId,
+ mobileLogger,
+ connectionsRepo.subscriptionsById.map { subs -> subs[subId] },
+ defaultNetworkName,
+ networkNameSeparator,
+ carrierConfigRepo.getOrCreateConfigForSubId(subId),
+ telephonyManager.createForSubscriptionId(subId),
+ )
+ }
+ FullMobileConnectionRepositoryKairos(
+ subId = subId,
+ tableLogBuffer = mobileLogger,
+ mobileRepo = mobileRepo,
+ carrierMergedRepoSpec =
+ buildSpec {
+ activated { mergedRepoFactory.build(subId, mobileLogger, mobileRepo) }
+ },
+ isCarrierMerged = connectionsRepo.carrierMergedSelector[subId],
+ )
+ }
+ }
+
+ companion object {
+ /** The buffer size to use for logging. */
+ private const val MOBILE_CONNECTION_BUFFER_SIZE = 100
+
+ /** Returns a log buffer name for a mobile connection with the given [subId]. */
+ fun tableBufferLogName(subId: Int): String = "MobileConnectionLog[$subId]"
+ }
+
+ @dagger.Module
+ interface Module {
+ @Binds
+ fun bindImpl(
+ impl: MobileConnectionRepositoryKairosFactoryImpl
+ ): MobileConnectionsRepositoryKairosImpl.ConnectionRepoFactory
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt
new file mode 100644
index 000000000000..4580ad974b29
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorKairos.kt
@@ -0,0 +1,380 @@
+/*
+ * Copyright (C) 2022 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.domain.interactor
+
+import android.content.Context
+import com.android.internal.telephony.flags.Flags
+import com.android.settingslib.SignalIcon.MobileIconGroup
+import com.android.settingslib.graph.SignalDrawable
+import com.android.settingslib.mobile.MobileIconCarrierIdOverrides
+import com.android.settingslib.mobile.MobileIconCarrierIdOverridesImpl
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState.Connected
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel.DefaultIcon
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.NetworkTypeIconModel.OverriddenIcon
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import com.android.systemui.statusbar.pipeline.satellite.ui.model.SatelliteIconModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+interface MobileIconInteractorKairos {
+ /** The table log created for this connection */
+ val tableLogBuffer: TableLogBuffer
+
+ /** The current mobile data activity */
+ val activity: Flow<DataActivityModel>
+
+ /** See [MobileConnectionsRepository.mobileIsDefault]. */
+ val mobileIsDefault: Flow<Boolean>
+
+ /**
+ * True when telephony tells us that the data state is CONNECTED. See
+ * [android.telephony.TelephonyCallback.DataConnectionStateListener] for more details. We
+ * consider this connection to be serving data, and thus want to show a network type icon, when
+ * data is connected. Other data connection states would typically cause us not to show the icon
+ */
+ val isDataConnected: StateFlow<Boolean>
+
+ /** True if we consider this connection to be in service, i.e. can make calls */
+ val isInService: StateFlow<Boolean>
+
+ /** True if this connection is emergency only */
+ val isEmergencyOnly: StateFlow<Boolean>
+
+ /** Observable for the data enabled state of this connection */
+ val isDataEnabled: StateFlow<Boolean>
+
+ /** True if the RAT icon should always be displayed and false otherwise. */
+ val alwaysShowDataRatIcon: StateFlow<Boolean>
+
+ /** Canonical representation of the current mobile signal strength as a triangle. */
+ val signalLevelIcon: StateFlow<SignalIconModel>
+
+ /** Observable for RAT type (network type) indicator */
+ val networkTypeIconGroup: StateFlow<NetworkTypeIconModel>
+
+ /** Whether or not to show the slice attribution */
+ val showSliceAttribution: StateFlow<Boolean>
+
+ /** True if this connection is satellite-based */
+ val isNonTerrestrial: StateFlow<Boolean>
+
+ /**
+ * Provider name for this network connection. The name can be one of 3 values:
+ * 1. The default network name, if one is configured
+ * 2. A derived name based off of the intent [ACTION_SERVICE_PROVIDERS_UPDATED]
+ * 3. Or, in the case where the repository sends us the default network name, we check for an
+ * override in [connectionInfo.operatorAlphaShort], a value that is derived from
+ * [ServiceState]
+ */
+ val networkName: StateFlow<NetworkNameModel>
+
+ /**
+ * Provider name for this network connection. The name can be one of 3 values:
+ * 1. The default network name, if one is configured
+ * 2. A name provided by the [SubscriptionModel] of this network connection
+ * 3. Or, in the case where the repository sends us the default network name, we check for an
+ * override in [connectionInfo.operatorAlphaShort], a value that is derived from
+ * [ServiceState]
+ *
+ * TODO(b/296600321): De-duplicate this field with [networkName] after determining the data
+ * provided is identical
+ */
+ val carrierName: StateFlow<String>
+
+ /** True if there is only one active subscription. */
+ val isSingleCarrier: StateFlow<Boolean>
+
+ /**
+ * True if this connection is considered roaming. The roaming bit can come from [ServiceState],
+ * or directly from the telephony manager's CDMA ERI number value. Note that we don't consider a
+ * connection to be roaming while carrier network change is active
+ */
+ val isRoaming: StateFlow<Boolean>
+
+ /** See [MobileIconsInteractor.isForceHidden]. */
+ val isForceHidden: Flow<Boolean>
+
+ /** See [MobileConnectionRepository.isAllowedDuringAirplaneMode]. */
+ val isAllowedDuringAirplaneMode: StateFlow<Boolean>
+
+ /** True when in carrier network change mode */
+ val carrierNetworkChangeActive: StateFlow<Boolean>
+}
+
+/** Interactor for a single mobile connection. This connection _should_ have one subscription ID */
+@Suppress("EXPERIMENTAL_IS_NOT_ENABLED")
+class MobileIconInteractorKairosImpl(
+ @Background scope: CoroutineScope,
+ defaultSubscriptionHasDataEnabled: StateFlow<Boolean>,
+ override val alwaysShowDataRatIcon: StateFlow<Boolean>,
+ alwaysUseCdmaLevel: StateFlow<Boolean>,
+ override val isSingleCarrier: StateFlow<Boolean>,
+ override val mobileIsDefault: StateFlow<Boolean>,
+ defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>,
+ defaultMobileIconGroup: StateFlow<MobileIconGroup>,
+ isDefaultConnectionFailed: StateFlow<Boolean>,
+ override val isForceHidden: Flow<Boolean>,
+ connectionRepository: MobileConnectionRepository,
+ private val context: Context,
+ val carrierIdOverrides: MobileIconCarrierIdOverrides = MobileIconCarrierIdOverridesImpl(),
+) : MobileIconInteractor, MobileIconInteractorKairos {
+ override val tableLogBuffer: TableLogBuffer = connectionRepository.tableLogBuffer
+
+ override val activity = connectionRepository.dataActivityDirection
+
+ override val isDataEnabled: StateFlow<Boolean> = connectionRepository.dataEnabled
+
+ override val carrierNetworkChangeActive: StateFlow<Boolean> =
+ connectionRepository.carrierNetworkChangeActive
+
+ // True if there exists _any_ icon override for this carrierId. Note that overrides can include
+ // any or none of the icon groups defined in MobileMappings, so we still need to check on a
+ // per-network-type basis whether or not the given icon group is overridden
+ private val carrierIdIconOverrideExists =
+ connectionRepository.carrierId
+ .map { carrierIdOverrides.carrierIdEntryExists(it) }
+ .distinctUntilChanged()
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val networkName =
+ combine(connectionRepository.operatorAlphaShort, connectionRepository.networkName) {
+ operatorAlphaShort,
+ networkName ->
+ if (networkName is NetworkNameModel.Default && operatorAlphaShort != null) {
+ NetworkNameModel.IntentDerived(operatorAlphaShort)
+ } else {
+ networkName
+ }
+ }
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectionRepository.networkName.value,
+ )
+
+ override val carrierName =
+ combine(connectionRepository.operatorAlphaShort, connectionRepository.carrierName) {
+ operatorAlphaShort,
+ networkName ->
+ if (networkName is NetworkNameModel.Default && operatorAlphaShort != null) {
+ operatorAlphaShort
+ } else {
+ networkName.name
+ }
+ }
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ connectionRepository.carrierName.value.name,
+ )
+
+ /** What the mobile icon would be before carrierId overrides */
+ private val defaultNetworkType: StateFlow<MobileIconGroup> =
+ combine(
+ connectionRepository.resolvedNetworkType,
+ defaultMobileIconMapping,
+ defaultMobileIconGroup,
+ ) { resolvedNetworkType, mapping, defaultGroup ->
+ when (resolvedNetworkType) {
+ is ResolvedNetworkType.CarrierMergedNetworkType ->
+ resolvedNetworkType.iconGroupOverride
+ else -> {
+ mapping[resolvedNetworkType.lookupKey] ?: defaultGroup
+ }
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), defaultMobileIconGroup.value)
+
+ override val networkTypeIconGroup =
+ combine(defaultNetworkType, carrierIdIconOverrideExists) { networkType, overrideExists ->
+ // DefaultIcon comes out of the icongroup lookup, we check for overrides here
+ if (overrideExists) {
+ val iconOverride =
+ carrierIdOverrides.getOverrideFor(
+ connectionRepository.carrierId.value,
+ networkType.name,
+ context.resources,
+ )
+ if (iconOverride > 0) {
+ OverriddenIcon(networkType, iconOverride)
+ } else {
+ DefaultIcon(networkType)
+ }
+ } else {
+ DefaultIcon(networkType)
+ }
+ }
+ .distinctUntilChanged()
+ .logDiffsForTable(
+ tableLogBuffer = tableLogBuffer,
+ initialValue = DefaultIcon(defaultMobileIconGroup.value),
+ )
+ .stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ DefaultIcon(defaultMobileIconGroup.value),
+ )
+
+ override val showSliceAttribution: StateFlow<Boolean> =
+ combine(
+ connectionRepository.allowNetworkSliceIndicator,
+ connectionRepository.hasPrioritizedNetworkCapabilities,
+ ) { allowed, hasPrioritizedNetworkCapabilities ->
+ allowed && hasPrioritizedNetworkCapabilities
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isNonTerrestrial: StateFlow<Boolean> = connectionRepository.isNonTerrestrial
+
+ override val isRoaming: StateFlow<Boolean> =
+ combine(
+ connectionRepository.carrierNetworkChangeActive,
+ connectionRepository.isGsm,
+ connectionRepository.isRoaming,
+ connectionRepository.cdmaRoaming,
+ ) { carrierNetworkChangeActive, isGsm, isRoaming, cdmaRoaming ->
+ if (carrierNetworkChangeActive) {
+ false
+ } else if (isGsm) {
+ isRoaming
+ } else {
+ cdmaRoaming
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ private val level: StateFlow<Int> =
+ combine(
+ connectionRepository.isGsm,
+ connectionRepository.primaryLevel,
+ connectionRepository.cdmaLevel,
+ alwaysUseCdmaLevel,
+ ) { isGsm, primaryLevel, cdmaLevel, alwaysUseCdmaLevel ->
+ when {
+ // GSM connections should never use the CDMA level
+ isGsm -> primaryLevel
+ alwaysUseCdmaLevel -> cdmaLevel
+ else -> primaryLevel
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
+
+ private val numberOfLevels: StateFlow<Int> = connectionRepository.numberOfLevels
+
+ override val isDataConnected: StateFlow<Boolean> =
+ connectionRepository.dataConnectionState
+ .map { it == Connected }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isInService = connectionRepository.isInService
+
+ override val isEmergencyOnly: StateFlow<Boolean> = connectionRepository.isEmergencyOnly
+
+ override val isAllowedDuringAirplaneMode = connectionRepository.isAllowedDuringAirplaneMode
+
+ /** Whether or not to show the error state of [SignalDrawable] */
+ private val showExclamationMark: StateFlow<Boolean> =
+ combine(defaultSubscriptionHasDataEnabled, isDefaultConnectionFailed, isInService) {
+ isDefaultDataEnabled,
+ isDefaultConnectionFailed,
+ isInService ->
+ !isDefaultDataEnabled || isDefaultConnectionFailed || !isInService
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), true)
+
+ private val cellularShownLevel: StateFlow<Int> =
+ combine(level, isInService, connectionRepository.inflateSignalStrength) {
+ level,
+ isInService,
+ inflate ->
+ if (isInService) {
+ if (inflate) level + 1 else level
+ } else 0
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
+
+ // Satellite level is unaffected by the inflateSignalStrength property
+ // See b/346904529 for details
+ private val satelliteShownLevel: StateFlow<Int> =
+ if (Flags.carrierRoamingNbIotNtn()) {
+ connectionRepository.satelliteLevel
+ } else {
+ combine(level, isInService) { level, isInService -> if (isInService) level else 0 }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
+
+ private val cellularIcon: Flow<SignalIconModel.Cellular> =
+ combine(
+ cellularShownLevel,
+ numberOfLevels,
+ showExclamationMark,
+ carrierNetworkChangeActive,
+ ) { cellularShownLevel, numberOfLevels, showExclamationMark, carrierNetworkChange ->
+ SignalIconModel.Cellular(
+ cellularShownLevel,
+ numberOfLevels,
+ showExclamationMark,
+ carrierNetworkChange,
+ )
+ }
+
+ private val satelliteIcon: Flow<SignalIconModel.Satellite> =
+ satelliteShownLevel.map {
+ SignalIconModel.Satellite(
+ level = it,
+ icon =
+ SatelliteIconModel.fromSignalStrength(it)
+ ?: SatelliteIconModel.fromSignalStrength(0)!!,
+ )
+ }
+
+ override val signalLevelIcon: StateFlow<SignalIconModel> = run {
+ val initial =
+ SignalIconModel.Cellular(
+ cellularShownLevel.value,
+ numberOfLevels.value,
+ showExclamationMark.value,
+ carrierNetworkChangeActive.value,
+ )
+ isNonTerrestrial
+ .flatMapLatest { ntn ->
+ if (ntn) {
+ satelliteIcon
+ } else {
+ cellularIcon
+ }
+ }
+ .distinctUntilChanged()
+ .logDiffsForTable(tableLogBuffer, columnPrefix = "icon", initialValue = initial)
+ .stateIn(scope, SharingStarted.WhileSubscribed(), initial)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt
new file mode 100644
index 000000000000..e8e0a833af2a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorKairos.kt
@@ -0,0 +1,452 @@
+/*
+ * Copyright (C) 2022 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.domain.interactor
+
+import android.content.Context
+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.mobile.TelephonyIcons
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Background
+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.NewStatusBarIcons
+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
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository
+import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
+import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlot
+import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
+import com.android.systemui.statusbar.policy.data.repository.UserSetupRepository
+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
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transformLatest
+
+/**
+ * Business layer logic for the set of mobile subscription icons.
+ *
+ * This interactor represents known set of mobile subscriptions (represented by [SubscriptionInfo]).
+ * The list of subscriptions is filtered based on the opportunistic flags on the infos.
+ *
+ * It provides the default mapping between the telephony display info and the icon group that
+ * represents each RAT (LTE, 3G, etc.), as well as can produce an interactor for each individual
+ * icon
+ */
+interface MobileIconsInteractorKairos {
+ /** See [MobileConnectionsRepository.mobileIsDefault]. */
+ val mobileIsDefault: StateFlow<Boolean>
+
+ /** List of subscriptions, potentially filtered for CBRS */
+ val filteredSubscriptions: Flow<List<SubscriptionModel>>
+
+ /** Subscription ID of the current default data subscription */
+ val defaultDataSubId: Flow<Int?>
+
+ /**
+ * The current list of [MobileIconInteractor]s associated with the current list of
+ * [filteredSubscriptions]
+ */
+ val icons: StateFlow<List<MobileIconInteractor>>
+
+ /** Whether the mobile icons can be stacked vertically. */
+ val isStackable: StateFlow<Boolean>
+
+ /**
+ * Observable for the subscriptionId of the current mobile data connection. Null if we don't
+ * have a valid subscription id
+ */
+ val activeMobileDataSubscriptionId: StateFlow<Int?>
+
+ /** True if the active mobile data subscription has data enabled */
+ val activeDataConnectionHasDataEnabled: StateFlow<Boolean>
+
+ /**
+ * Flow providing a reference to the Interactor for the active data subId. This represents the
+ * [MobileIconInteractor] responsible for the active data connection, if any.
+ */
+ val activeDataIconInteractor: StateFlow<MobileIconInteractor?>
+
+ /** True if the RAT icon should always be displayed and false otherwise. */
+ val alwaysShowDataRatIcon: StateFlow<Boolean>
+
+ /** True if the CDMA level should be preferred over the primary level. */
+ val alwaysUseCdmaLevel: StateFlow<Boolean>
+
+ /** True if there is only one active subscription. */
+ val isSingleCarrier: StateFlow<Boolean>
+
+ /** The icon mapping from network type to [MobileIconGroup] for the default subscription */
+ val defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>
+
+ /** Fallback [MobileIconGroup] in the case where there is no icon in the mapping */
+ val defaultMobileIconGroup: StateFlow<MobileIconGroup>
+
+ /** True only if the default network is mobile, and validation also failed */
+ val isDefaultConnectionFailed: StateFlow<Boolean>
+
+ /** True once the user has been set up */
+ val isUserSetUp: StateFlow<Boolean>
+
+ /** True if we're configured to force-hide the mobile icons and false otherwise. */
+ val isForceHidden: Flow<Boolean>
+
+ /**
+ * True if the device-level service state (with -1 subscription id) reports emergency calls
+ * only. This value is only useful when there are no other subscriptions OR all existing
+ * subscriptions report that they are not in service.
+ */
+ val isDeviceInEmergencyCallsOnlyMode: Flow<Boolean>
+
+ /**
+ * Vends out a [MobileIconInteractor] tracking the [MobileConnectionRepository] for the given
+ * subId.
+ */
+ fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@Suppress("EXPERIMENTAL_IS_NOT_ENABLED")
+@SysUISingleton
+class MobileIconsInteractorKairosImpl
+@Inject
+constructor(
+ private val mobileConnectionsRepo: MobileConnectionsRepository,
+ private val carrierConfigTracker: CarrierConfigTracker,
+ @MobileSummaryLog private val tableLogger: TableLogBuffer,
+ connectivityRepository: ConnectivityRepository,
+ userSetupRepo: UserSetupRepository,
+ @Background private val scope: CoroutineScope,
+ private val context: Context,
+ private val featureFlagsClassic: FeatureFlagsClassic,
+) : MobileIconsInteractor, MobileIconsInteractorKairos {
+
+ // Weak reference lookup for created interactors
+ private val reuseCache = mutableMapOf<Int, WeakReference<MobileIconInteractor>>()
+
+ override val mobileIsDefault =
+ combine(
+ mobileConnectionsRepo.mobileIsDefault,
+ mobileConnectionsRepo.hasCarrierMergedConnection,
+ ) { mobileIsDefault, hasCarrierMergedConnection ->
+ // Because carrier merged networks are displayed as mobile networks, they're part of
+ // the `isDefault` calculation. See b/272586234.
+ mobileIsDefault || hasCarrierMergedConnection
+ }
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "mobileIsDefault",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val activeMobileDataSubscriptionId: StateFlow<Int?> =
+ mobileConnectionsRepo.activeMobileDataSubscriptionId
+
+ override val activeDataConnectionHasDataEnabled: StateFlow<Boolean> =
+ mobileConnectionsRepo.activeMobileDataRepository
+ .flatMapLatest { it?.dataEnabled ?: flowOf(false) }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val activeDataIconInteractor: StateFlow<MobileIconInteractor?> =
+ mobileConnectionsRepo.activeMobileDataSubscriptionId
+ .mapLatest {
+ if (it != null) {
+ getMobileConnectionInteractorForSubId(it)
+ } else {
+ null
+ }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+
+ private val unfilteredSubscriptions: Flow<List<SubscriptionModel>> =
+ mobileConnectionsRepo.subscriptions
+
+ /** Any filtering that we can do based purely on the info of each subscription individually. */
+ private val subscriptionsBasedFilteredSubs =
+ unfilteredSubscriptions
+ .map { it.filterBasedOnProvisioning().filterBasedOnNtn() }
+ .distinctUntilChanged()
+
+ private fun List<SubscriptionModel>.filterBasedOnProvisioning(): List<SubscriptionModel> =
+ if (!featureFlagsClassic.isEnabled(FILTER_PROVISIONING_NETWORK_SUBSCRIPTIONS)) {
+ this
+ } else {
+ this.filter { it.profileClass != PROFILE_CLASS_PROVISIONING }
+ }
+
+ /**
+ * Subscriptions that exclusively support non-terrestrial networks should **never** directly
+ * show any iconography in the status bar. These subscriptions only exist to provide a backing
+ * for the device-based satellite connections, and the iconography for those connections are
+ * already being handled in
+ * [com.android.systemui.statusbar.pipeline.satellite.data.DeviceBasedSatelliteRepository]. We
+ * need to filter out those subscriptions here so we guarantee the subscription never turns into
+ * an icon. See b/336881301.
+ */
+ private fun List<SubscriptionModel>.filterBasedOnNtn(): List<SubscriptionModel> {
+ return this.filter { !it.isExclusivelyNonTerrestrial }
+ }
+
+ /**
+ * Generally, SystemUI wants to show iconography for each subscription that is listed by
+ * [SubscriptionManager]. However, in the case of opportunistic subscriptions, we want to only
+ * show a single representation of the pair of subscriptions. The docs define opportunistic as:
+ *
+ * "A subscription is opportunistic (if) the network it connects to has limited coverage"
+ * https://developer.android.com/reference/android/telephony/SubscriptionManager#setOpportunistic(boolean,%20int)
+ *
+ * In the case of opportunistic networks (typically CBRS), we will filter out one of the
+ * subscriptions based on
+ * [CarrierConfigManager.KEY_ALWAYS_SHOW_PRIMARY_SIGNAL_BAR_IN_OPPORTUNISTIC_NETWORK_BOOLEAN],
+ * and by checking which subscription is opportunistic, or which one is active.
+ */
+ override val filteredSubscriptions: Flow<List<SubscriptionModel>> =
+ combine(
+ subscriptionsBasedFilteredSubs,
+ mobileConnectionsRepo.activeMobileDataSubscriptionId,
+ connectivityRepository.vcnSubId,
+ ) { preFilteredSubs, activeId, vcnSubId ->
+ filterSubsBasedOnOpportunistic(preFilteredSubs, activeId, vcnSubId)
+ }
+ .distinctUntilChanged()
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "filteredSubscriptions",
+ initialValue = listOf(),
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), listOf())
+
+ private fun filterSubsBasedOnOpportunistic(
+ subList: List<SubscriptionModel>,
+ activeId: Int?,
+ vcnSubId: Int?,
+ ): List<SubscriptionModel> {
+ // Based on the old logic,
+ if (subList.size != 2) {
+ return subList
+ }
+
+ val info1 = subList[0]
+ val info2 = subList[1]
+
+ // Filtering only applies to subscriptions in the same group
+ if (info1.groupUuid == null || info1.groupUuid != info2.groupUuid) {
+ return subList
+ }
+
+ // If both subscriptions are primary, show both
+ if (!info1.isOpportunistic && !info2.isOpportunistic) {
+ return subList
+ }
+
+ // NOTE: at this point, we are now returning a single SubscriptionInfo
+
+ // If carrier required, always show the icon of the primary subscription.
+ // Otherwise, show whichever subscription is currently active for internet.
+ if (carrierConfigTracker.alwaysShowPrimarySignalBarInOpportunisticNetworkDefault) {
+ // return the non-opportunistic info
+ return if (info1.isOpportunistic) listOf(info2) else listOf(info1)
+ } else {
+ // It's possible for the subId of the VCN to disagree with the active subId in
+ // cases where the system has tried to switch but found no connection. In these
+ // scenarios, VCN will always have the subId that we want to use, so use that
+ // value instead of the activeId reported by telephony
+ val subIdToKeep = vcnSubId ?: activeId
+
+ return if (info1.subscriptionId == subIdToKeep) {
+ listOf(info1)
+ } else {
+ listOf(info2)
+ }
+ }
+ }
+
+ override val defaultDataSubId = mobileConnectionsRepo.defaultDataSubId
+
+ override val icons =
+ filteredSubscriptions
+ .mapLatest { subs ->
+ subs.map { getMobileConnectionInteractorForSubId(it.subscriptionId) }
+ }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
+
+ override val isStackable =
+ if (NewStatusBarIcons.isEnabled && StatusBarRootModernization.isEnabled) {
+ icons.flatMapLatest { icons ->
+ combine(icons.map { it.signalLevelIcon }) { signalLevelIcons ->
+ // These are only stackable if:
+ // - They are cellular
+ // - There's exactly two
+ // - They have the same number of levels
+ signalLevelIcons.filterIsInstance<SignalIconModel.Cellular>().let {
+ it.size == 2 && it[0].numberOfLevels == it[1].numberOfLevels
+ }
+ }
+ }
+ } 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).
+ *
+ * This condition only applies if
+ * 1. A and B are in the same subscription group (e.g. for CBRS data switching) and
+ * 2. A was validated before the switch
+ *
+ * The goal of this is to minimize the flickering in the UI of the cellular indicator
+ */
+ private val forcingCellularValidation =
+ mobileConnectionsRepo.activeSubChangedInGroupEvent
+ .filter { mobileConnectionsRepo.defaultConnectionIsValidated.value }
+ .transformLatest {
+ emit(true)
+ delay(2000)
+ emit(false)
+ }
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "forcingValidation",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ /**
+ * Mapping from network type to [MobileIconGroup] using the config generated for the default
+ * subscription Id. This mapping is the same for every subscription.
+ */
+ override val defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>> =
+ mobileConnectionsRepo.defaultMobileIconMapping.stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ initialValue = mapOf(),
+ )
+
+ override val alwaysShowDataRatIcon: StateFlow<Boolean> =
+ mobileConnectionsRepo.defaultDataSubRatConfig
+ .mapLatest { it.alwaysShowDataRatIcon }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val alwaysUseCdmaLevel: StateFlow<Boolean> =
+ mobileConnectionsRepo.defaultDataSubRatConfig
+ .mapLatest { it.alwaysShowCdmaRssi }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isSingleCarrier: StateFlow<Boolean> =
+ mobileConnectionsRepo.subscriptions
+ .map { it.size == 1 }
+ .logDiffsForTable(
+ tableLogger,
+ columnPrefix = LOGGING_PREFIX,
+ columnName = "isSingleCarrier",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ /** If there is no mapping in [defaultMobileIconMapping], then use this default icon group */
+ override val defaultMobileIconGroup: StateFlow<MobileIconGroup> =
+ mobileConnectionsRepo.defaultMobileIconGroup.stateIn(
+ scope,
+ SharingStarted.WhileSubscribed(),
+ initialValue = TelephonyIcons.G,
+ )
+
+ /**
+ * We want to show an error state when cellular has actually failed to validate, but not if some
+ * other transport type is active, because then we expect there not to be validation.
+ */
+ override val isDefaultConnectionFailed: StateFlow<Boolean> =
+ combine(
+ mobileIsDefault,
+ mobileConnectionsRepo.defaultConnectionIsValidated,
+ forcingCellularValidation,
+ ) { mobileIsDefault, defaultConnectionIsValidated, forcingCellularValidation ->
+ when {
+ !mobileIsDefault -> false
+ forcingCellularValidation -> false
+ else -> !defaultConnectionIsValidated
+ }
+ }
+ .logDiffsForTable(
+ tableLogger,
+ LOGGING_PREFIX,
+ columnName = "isDefaultConnectionFailed",
+ initialValue = false,
+ )
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isUserSetUp: StateFlow<Boolean> = userSetupRepo.isUserSetUp
+
+ override val isForceHidden: Flow<Boolean> =
+ connectivityRepository.forceHiddenSlots
+ .map { it.contains(ConnectivitySlot.MOBILE) }
+ .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
+ override val isDeviceInEmergencyCallsOnlyMode: Flow<Boolean> =
+ mobileConnectionsRepo.isDeviceEmergencyCallCapable
+
+ /** Vends out new [MobileIconInteractor] for a particular subId */
+ override fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
+ reuseCache[subId]?.get() ?: createMobileConnectionInteractorForSubId(subId)
+
+ private fun createMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
+ MobileIconInteractorImpl(
+ scope,
+ activeDataConnectionHasDataEnabled,
+ alwaysShowDataRatIcon,
+ alwaysUseCdmaLevel,
+ isSingleCarrier,
+ mobileIsDefault,
+ defaultMobileIconMapping,
+ defaultMobileIconGroup,
+ isDefaultConnectionFailed,
+ isForceHidden,
+ mobileConnectionsRepo.getRepoForSubId(subId),
+ context,
+ )
+ .also { reuseCache[subId] = WeakReference(it) }
+
+ companion object {
+ private const val LOGGING_PREFIX = "Intr"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt
new file mode 100644
index 000000000000..a6209fa72b53
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/Producer.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.util.kotlin
+
+/** Like a [javax.inject.Provider], but [get] is a `suspend fun`. */
+fun interface Producer<out T> {
+ suspend fun get(): T
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt
index df74404aaaf8..db948e947a26 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigTest.kt
@@ -17,7 +17,6 @@
package com.android.systemui.statusbar.pipeline.mobile.data.model
import android.os.PersistableBundle
-import android.telephony.CarrierConfigManager
import android.telephony.CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL
import android.telephony.CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL
import android.telephony.CarrierConfigManager.KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL
@@ -37,7 +36,7 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
@Before
fun setUp() {
- underTest = SystemUiCarrierConfig(SUB_1_ID, createTestConfig())
+ underTest = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
}
@Test
@@ -46,7 +45,7 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
assertThat(underTest.isUsingDefault).isTrue()
// ANY new config means we're no longer tracking defaults
- underTest.processNewCarrierConfig(createTestConfig())
+ underTest.processNewCarrierConfig(testCarrierConfig())
assertThat(underTest.isUsingDefault).isFalse()
}
@@ -58,7 +57,7 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
assertThat(underTest.allowNetworkSliceIndicator.value).isTrue()
underTest.processNewCarrierConfig(
- configWithOverrides(
+ testCarrierConfigWithOverrides(
KEY_INFLATE_SIGNAL_STRENGTH_BOOL to true,
KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL to true,
KEY_SHOW_5G_SLICE_ICON_BOOL to false,
@@ -81,11 +80,11 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
underTest =
SystemUiCarrierConfig(
SUB_1_ID,
- configWithOverrides(
+ testCarrierConfigWithOverrides(
KEY_INFLATE_SIGNAL_STRENGTH_BOOL to true,
KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL to true,
KEY_SHOW_5G_SLICE_ICON_BOOL to true,
- )
+ ),
)
assertThat(underTest.isUsingDefault).isTrue()
@@ -104,26 +103,5 @@ class SystemUiCarrierConfigTest : SysuiTestCase() {
companion object {
private const val SUB_1_ID = 1
-
- /**
- * In order to keep us from having to update every place that might want to create a config,
- * make sure to add new keys here
- */
- fun createTestConfig() =
- PersistableBundle().also {
- it.putBoolean(CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
- it.putBoolean(CarrierConfigManager.KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL, false)
- it.putBoolean(CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL, true)
- }
-
- /** Override the default config with the given (key, value) pair */
- fun configWithOverride(key: String, override: Boolean): PersistableBundle =
- createTestConfig().also { it.putBoolean(key, override) }
-
- /** Override any number of configs from the default */
- fun configWithOverrides(vararg overrides: Pair<String, Boolean>) =
- createTestConfig().also { config ->
- overrides.forEach { (key, value) -> config.putBoolean(key, value) }
- }
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt
index d074fc256133..e1cc25972105 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/CarrierConfigRepositoryImplTest.kt
@@ -26,7 +26,7 @@ import com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession
import com.android.systemui.SysuiTestCase
import com.android.systemui.dump.DumpManager
import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.createTestConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.launchIn
@@ -163,8 +163,8 @@ class CarrierConfigRepositoryImplTest : SysuiTestCase() {
private const val SUB_ID_1 = 1
private const val SUB_ID_2 = 2
- private val DEFAULT_CONFIG = createTestConfig()
- private val CONFIG_1 = createTestConfig()
- private val CONFIG_2 = createTestConfig()
+ private val DEFAULT_CONFIG = testCarrierConfig()
+ private val CONFIG_1 = testCarrierConfig()
+ private val CONFIG_2 = testCarrierConfig()
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
index a51e919636ee..ed8be9b253ab 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
@@ -86,8 +86,8 @@ import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetwork
import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.configWithOverride
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.createTestConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfigWithOverride
import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.signalStrength
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.telephonyDisplayInfo
@@ -129,7 +129,7 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
@Mock private lateinit var context: Context
private val mobileMappings = FakeMobileMappingsProxy()
- private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, createTestConfig())
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -1314,13 +1314,13 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
)
assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS + 1)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
)
assertThat(latest).isEqualTo(DEFAULT_NUM_LEVELS)
@@ -1336,13 +1336,13 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
assertThat(latest).isEqualTo(false)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, true)
)
assertThat(latest).isEqualTo(true)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ testCarrierConfigWithOverride(KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
)
assertThat(latest).isEqualTo(false)
@@ -1354,13 +1354,13 @@ class MobileConnectionRepositoryTest : SysuiTestCase() {
val latest by collectLastValue(underTest.allowNetworkSliceIndicator)
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, true)
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, true)
)
assertThat(latest).isTrue()
systemUiCarrierConfig.processNewCarrierConfig(
- configWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, false)
+ testCarrierConfigWithOverride(KEY_SHOW_5G_SLICE_ICON_BOOL, false)
)
assertThat(latest).isFalse()
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
index ec260fcc7a65..6f21e795532b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
@@ -41,7 +41,7 @@ import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameMode
import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
-import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest
+import com.android.systemui.statusbar.pipeline.mobile.data.model.testCarrierConfig
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.getTelephonyCallbackForType
import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileTelephonyHelpers.signalStrength
import com.android.systemui.statusbar.pipeline.mobile.util.FakeMobileMappingsProxy
@@ -105,11 +105,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
@Mock private lateinit var subscriptionModel: StateFlow<SubscriptionModel?>
private val mobileMappings = FakeMobileMappingsProxy()
- private val systemUiCarrierConfig =
- SystemUiCarrierConfig(
- SUB_1_ID,
- SystemUiCarrierConfigTest.createTestConfig(),
- )
+ private val systemUiCarrierConfig = SystemUiCarrierConfig(SUB_1_ID, testCarrierConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -185,12 +181,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
val job = underTest.dataActivityDirection.onEach { latest = it }.launchIn(this)
assertThat(latest)
- .isEqualTo(
- DataActivityModel(
- hasActivityIn = true,
- hasActivityOut = true,
- )
- )
+ .isEqualTo(DataActivityModel(hasActivityIn = true, hasActivityOut = true))
displayInfoJob.cancel()
job.cancel()
@@ -209,7 +200,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
connectionCallback.onDataConnectionStateChanged(
TelephonyManager.DATA_CONNECTED,
- 200 /* unused */
+ 200, /* unused */
)
flipActivity(100, activityCallback)
@@ -320,10 +311,7 @@ class MobileConnectionTelephonySmokeTests : SysuiTestCase() {
job.cancel()
}
- private fun flipActivity(
- times: Int,
- callback: DataActivityListener,
- ) {
+ private fun flipActivity(times: Int, callback: DataActivityListener) {
repeat(times) { index -> callback.onDataActivity(index % 4) }
}
diff --git a/packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt b/packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt
new file mode 100644
index 000000000000..516053d00ee2
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/android/net/ConnectivityManagerKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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 android.net
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.util.mockito.mockFixture
+
+var Kosmos.connectivityManager: ConnectivityManager by mockFixture()
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt
new file mode 100644
index 000000000000..927209f84f1d
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/CollectLastValue.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.kairos
+
+import com.android.systemui.coroutines.collectLastValue
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+
+/**
+ * Collect [state] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+@ExperimentalKairosApi
+fun <T> TestScope.collectLastValue(state: State<T>, kairosNetwork: KairosNetwork): KairosValue<T?> {
+ var value: T? = null
+ backgroundScope.launch { kairosNetwork.activateSpec { state.observe { value = it } } }
+ return KairosValueImpl {
+ runCurrent()
+ value
+ }
+}
+
+/**
+ * Collect [flow] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+@ExperimentalKairosApi
+fun <T> TestScope.collectLastValue(flow: Events<T>, kairosNetwork: KairosNetwork): KairosValue<T?> {
+ var value: T? = null
+ backgroundScope.launch { kairosNetwork.activateSpec { flow.observe { value = it } } }
+ return KairosValueImpl {
+ runCurrent()
+ value
+ }
+}
+
+/**
+ * Collect [flow] in a new [Job] and return a getter for the collection of values collected.
+ *
+ * ```
+ * fun myTest() = runTest {
+ * // ...
+ * val values by collectValues(underTest.flow)
+ * assertThat(values).isEqualTo(listOf(expected1, expected2, ...))
+ * }
+ * ```
+ */
+@ExperimentalKairosApi
+fun <T> TestScope.collectValues(
+ flow: Events<T>,
+ kairosNetwork: KairosNetwork,
+): KairosValue<List<T>> {
+ val values = mutableListOf<T>()
+ backgroundScope.launch { kairosNetwork.activateSpec { flow.observe { values.add(it) } } }
+ return KairosValueImpl {
+ runCurrent()
+ values.toList()
+ }
+}
+
+/** @see collectLastValue */
+interface KairosValue<T> : ReadOnlyProperty<Any?, T> {
+ val value: T
+}
+
+private class KairosValueImpl<T>(private val block: () -> T) : KairosValue<T> {
+ override val value: T
+ get() = block()
+
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T = value
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt
new file mode 100644
index 000000000000..d7f204183467
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kairos/KairosKosmos.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 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.kairos
+
+import com.android.systemui.KairosActivatable
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testScope
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+
+@ExperimentalKairosApi
+val Kosmos.kairos: KairosNetwork by Fixture { applicationCoroutineScope.launchKairosNetwork() }
+
+@ExperimentalKairosApi
+fun Kosmos.activateKairosActivatable(activatable: KairosActivatable) {
+ applicationCoroutineScope.launch { kairos.activateSpec { activatable.run { activate() } } }
+}
+
+@ExperimentalKairosApi
+fun <T : KairosActivatable> ActivatedKairosFixture(block: Kosmos.() -> T) = Fixture {
+ block().also { activateKairosActivatable(it) }
+}
+
+@ExperimentalKairosApi
+fun Kosmos.runKairosTest(timeout: Duration = 5.seconds, block: suspend KairosTestScope.() -> Unit) =
+ testScope.runTest(timeout) { KairosTestScopeImpl(this@runKairosTest, this, kairos).block() }
+
+@ExperimentalKairosApi
+interface KairosTestScope : Kosmos {
+ fun <T> State<T>.collectLastValue(): KairosValue<T?>
+
+ suspend fun <T> State<T>.sample(): T
+
+ fun <T : KairosActivatable> T.activated(): T
+}
+
+@ExperimentalKairosApi
+private class KairosTestScopeImpl(
+ kosmos: Kosmos,
+ val testScope: TestScope,
+ val kairos: KairosNetwork,
+) : KairosTestScope, Kosmos by kosmos {
+ override fun <T> State<T>.collectLastValue(): KairosValue<T?> =
+ testScope.collectLastValue(this@collectLastValue, kairos)
+
+ override suspend fun <T> State<T>.sample(): T = kairos.transact { sample() }
+
+ override fun <T : KairosActivatable> T.activated(): T =
+ this.also { activateKairosActivatable(it) }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt
new file mode 100644
index 000000000000..13b016342c15
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SystemUiCarrierConfigFakes.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.data.model
+
+import android.os.PersistableBundle
+import android.telephony.CarrierConfigManager
+
+/**
+ * In order to keep us from having to update every place that might want to create a config, make
+ * sure to add new keys here
+ */
+fun testCarrierConfig() =
+ PersistableBundle().also {
+ it.putBoolean(CarrierConfigManager.KEY_INFLATE_SIGNAL_STRENGTH_BOOL, false)
+ it.putBoolean(CarrierConfigManager.KEY_SHOW_OPERATOR_NAME_IN_STATUSBAR_BOOL, false)
+ it.putBoolean(CarrierConfigManager.KEY_SHOW_5G_SLICE_ICON_BOOL, true)
+ }
+
+/** Override the default config with the given (key, value) pair */
+fun testCarrierConfigWithOverride(key: String, override: Boolean): PersistableBundle =
+ testCarrierConfig().also { it.putBoolean(key, override) }
+
+/** Override any number of configs from the default */
+fun testCarrierConfigWithOverrides(vararg overrides: Pair<String, Boolean>) =
+ testCarrierConfig().also { config ->
+ overrides.forEach { (key, value) -> config.putBoolean(key, value) }
+ }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt
new file mode 100644
index 000000000000..8cf3ee8d3c1c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepositoryKairos.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.State
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
+import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository.Companion.DEFAULT_NETWORK_NAME
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository.Companion.DEFAULT_NUM_LEVELS
+import com.android.systemui.statusbar.pipeline.shared.data.model.DataActivityModel
+
+@ExperimentalKairosApi
+class FakeMobileConnectionRepositoryKairos(
+ override val subId: Int,
+ kairos: KairosNetwork,
+ override val tableLogBuffer: TableLogBuffer,
+) : MobileConnectionRepositoryKairos {
+ override val carrierId = MutableState(kairos, 0)
+ override val inflateSignalStrength = MutableState(kairos, false)
+ override val allowNetworkSliceIndicator = MutableState(kairos, true)
+ override val isEmergencyOnly = MutableState(kairos, false)
+ override val isRoaming = MutableState(kairos, false)
+ override val operatorAlphaShort = MutableState<String?>(kairos, null)
+ override val isInService = MutableState(kairos, false)
+ override val isNonTerrestrial = MutableState(kairos, false)
+ override val isGsm = MutableState(kairos, false)
+ override val cdmaLevel = MutableState(kairos, 0)
+ override val primaryLevel = MutableState(kairos, 0)
+ override val satelliteLevel = MutableState(kairos, 0)
+ override val dataConnectionState = MutableState(kairos, DataConnectionState.Disconnected)
+ override val dataActivityDirection =
+ MutableState(kairos, DataActivityModel(hasActivityIn = false, hasActivityOut = false))
+ override val carrierNetworkChangeActive = MutableState(kairos, false)
+ override val resolvedNetworkType =
+ MutableState<ResolvedNetworkType>(kairos, ResolvedNetworkType.UnknownNetworkType)
+ override val numberOfLevels = MutableState(kairos, DEFAULT_NUM_LEVELS)
+ override val dataEnabled = MutableState(kairos, true)
+ override val cdmaRoaming = MutableState(kairos, false)
+ override val networkName =
+ MutableState<NetworkNameModel>(kairos, NetworkNameModel.Default(DEFAULT_NETWORK_NAME))
+ override val carrierName =
+ MutableState<NetworkNameModel>(kairos, NetworkNameModel.Default(DEFAULT_NETWORK_NAME))
+ override val isAllowedDuringAirplaneMode = MutableState(kairos, false)
+ override val hasPrioritizedNetworkCapabilities = MutableState(kairos, false)
+ override val isInEcmMode: State<Boolean> = MutableState(kairos, false)
+
+ /**
+ * Set [primaryLevel] and [cdmaLevel]. Convenient when you don't care about the connection type
+ */
+ fun setAllLevels(level: Int) {
+ cdmaLevel.setValue(level)
+ primaryLevel.setValue(level)
+ }
+
+ /** Set the correct [resolvedNetworkType] for the given group via its lookup key */
+ fun setNetworkTypeKey(key: String) {
+ resolvedNetworkType.setValue(ResolvedNetworkType.DefaultNetworkType(key))
+ }
+
+ /**
+ * Set both [isRoaming] and [cdmaRoaming] properties, in the event that you don't care about the
+ * connection type
+ */
+ fun setAllRoaming(roaming: Boolean) {
+ isRoaming.setValue(roaming)
+ cdmaRoaming.setValue(roaming)
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt
new file mode 100644
index 000000000000..624b2cc12c89
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepositoryKairos.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyDisplayInfo
+import android.telephony.TelephonyManager
+import com.android.settingslib.SignalIcon
+import com.android.settingslib.mobile.MobileMappings
+import com.android.settingslib.mobile.TelephonyIcons
+import com.android.systemui.KairosBuilder
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.MutableEvents
+import com.android.systemui.kairos.MutableState
+import com.android.systemui.kairos.State
+import com.android.systemui.kairos.asIncremental
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.combine
+import com.android.systemui.kairos.map
+import com.android.systemui.kairos.mapValues
+import com.android.systemui.kairosBuilder
+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.FakeMobileMappingsProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
+
+// TODO(b/261632894): remove this in favor of the real impl or DemoMobileConnectionsRepositoryKairos
+@ExperimentalKairosApi
+class FakeMobileConnectionsRepositoryKairos(
+ kairos: KairosNetwork,
+ val tableLogBuffer: TableLogBuffer,
+ mobileMappings: MobileMappingsProxy = FakeMobileMappingsProxy(),
+) : MobileConnectionsRepositoryKairos, KairosBuilder by kairosBuilder() {
+
+ val GSM_KEY = mobileMappings.toIconKey(GSM)
+ val LTE_KEY = mobileMappings.toIconKey(LTE)
+ val UMTS_KEY = mobileMappings.toIconKey(UMTS)
+ val LTE_ADVANCED_KEY = mobileMappings.toIconKeyOverride(LTE_ADVANCED_PRO)
+
+ /**
+ * To avoid a reliance on [MobileMappings], we'll build a simpler map from network type to
+ * mobile icon. See TelephonyManager.NETWORK_TYPES for a list of types and [TelephonyIcons] for
+ * the exhaustive set of icons
+ */
+ val TEST_MAPPING: Map<String, SignalIcon.MobileIconGroup> =
+ mapOf(
+ GSM_KEY to TelephonyIcons.THREE_G,
+ LTE_KEY to TelephonyIcons.LTE,
+ UMTS_KEY to TelephonyIcons.FOUR_G,
+ LTE_ADVANCED_KEY to TelephonyIcons.NR_5G,
+ )
+
+ override val subscriptions = MutableState(kairos, emptyList<SubscriptionModel>())
+
+ override val mobileConnectionsBySubId = buildIncremental {
+ subscriptions
+ .map { it.associate { sub -> sub.subscriptionId to Unit } }
+ .asIncremental()
+ .mapValues { (subId, _) ->
+ buildSpec {
+ FakeMobileConnectionRepositoryKairos(subId, kairosNetwork, tableLogBuffer)
+ }
+ }
+ .applyLatestSpecForKey()
+ }
+
+ private val _activeMobileDataSubscriptionId = MutableState<Int?>(kairos, null)
+ override val activeMobileDataSubscriptionId: State<Int?> = _activeMobileDataSubscriptionId
+
+ override val activeMobileDataRepository: State<MobileConnectionRepositoryKairos?> =
+ combine(mobileConnectionsBySubId, activeMobileDataSubscriptionId) { conns, activeSub ->
+ conns[activeSub]
+ }
+
+ override val activeSubChangedInGroupEvent = MutableEvents<Unit>(kairos)
+
+ override val defaultDataSubId = MutableState(kairos, INVALID_SUBSCRIPTION_ID)
+
+ override val mobileIsDefault = MutableState(kairos, false)
+
+ override val hasCarrierMergedConnection = MutableState(kairos, false)
+
+ override val defaultConnectionIsValidated = MutableState(kairos, false)
+
+ override val defaultDataSubRatConfig = MutableState(kairos, MobileMappings.Config())
+
+ override val defaultMobileIconMapping = MutableState(kairos, TEST_MAPPING)
+
+ override val defaultMobileIconGroup = MutableState(kairos, DEFAULT_ICON)
+
+ override val isDeviceEmergencyCallCapable = MutableState(kairos, false)
+
+ override val isAnySimSecure = MutableState(kairos, false)
+
+ override val isInEcmMode: State<Boolean> = MutableState(kairos, false)
+
+ fun setActiveMobileDataSubscriptionId(subId: Int) {
+ // Simulate the filtering that the repo does
+ if (subId == INVALID_SUBSCRIPTION_ID) {
+ _activeMobileDataSubscriptionId.setValue(null)
+ } else {
+ _activeMobileDataSubscriptionId.setValue(subId)
+ }
+ }
+
+ companion object {
+ val DEFAULT_ICON = TelephonyIcons.G
+
+ // Use [MobileMappings] to define some simple definitions
+ const val GSM = TelephonyManager.NETWORK_TYPE_GSM
+ const val LTE = TelephonyManager.NETWORK_TYPE_LTE
+ const val UMTS = TelephonyManager.NETWORK_TYPE_UMTS
+ const val LTE_ADVANCED_PRO = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO
+ }
+}
+
+@ExperimentalKairosApi
+val MobileConnectionsRepositoryKairos.fake
+ get() = this as FakeMobileConnectionsRepositoryKairos
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt
new file mode 100644
index 000000000000..f57cf99c1309
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileDataRepositoryKairosKosmos.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2024 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.data.repository
+
+import android.content.applicationContext
+import android.telephony.SubscriptionManager
+import android.telephony.telephonyManager
+import com.android.keyguard.keyguardUpdateMonitor
+import com.android.systemui.broadcast.broadcastDispatcher
+import com.android.systemui.demoModeController
+import com.android.systemui.dump.dumpManager
+import com.android.systemui.kairos.ActivatedKairosFixture
+import com.android.systemui.kairos.ExperimentalKairosApi
+import com.android.systemui.kairos.KairosNetwork
+import com.android.systemui.kairos.MutableEvents
+import com.android.systemui.kairos.buildSpec
+import com.android.systemui.kairos.kairos
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.Kosmos.Fixture
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logcatTableLogBuffer
+import com.android.systemui.log.table.tableLogBufferFactory
+import com.android.systemui.statusbar.pipeline.airplane.data.repository.airplaneModeRepository
+import com.android.systemui.statusbar.pipeline.mobile.data.MobileInputLogger
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepositoryKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoModeMobileConnectionDataSourceKairos
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel
+import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryKairosImpl
+import com.android.systemui.statusbar.pipeline.mobile.util.FakeSubscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.mobile.util.SubscriptionManagerProxy
+import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.demo.DemoModeWifiDataSource
+import com.android.systemui.statusbar.pipeline.wifi.data.repository.wifiRepository
+import com.android.systemui.util.mockito.mockFixture
+import org.mockito.kotlin.mock
+
+@ExperimentalKairosApi
+var Kosmos.mobileConnectionsRepositoryKairos: MobileConnectionsRepositoryKairos by Fixture {
+ mobileRepositorySwitcherKairos
+}
+
+@ExperimentalKairosApi
+val Kosmos.fakeMobileConnectionsRepositoryKairos by ActivatedKairosFixture {
+ FakeMobileConnectionsRepositoryKairos(kairos, logcatTableLogBuffer(this), mobileMappingsProxy)
+}
+
+@ExperimentalKairosApi
+val Kosmos.demoMobileConnectionsRepositoryKairos by ActivatedKairosFixture {
+ DemoMobileConnectionsRepositoryKairos(
+ mobileDataSource = demoModeMobileConnectionDataSourceKairos,
+ wifiDataSource = wifiDataSource,
+ context = applicationContext,
+ logFactory = tableLogBufferFactory,
+ )
+}
+
+@ExperimentalKairosApi
+val Kosmos.demoModeMobileConnectionDataSourceKairos:
+ DemoModeMobileConnectionDataSourceKairos by Fixture {
+ FakeDemoModeMobileConnectionDataSourceKairos(kairos)
+}
+
+val Kosmos.wifiDataSource: DemoModeWifiDataSource by mockFixture()
+
+@ExperimentalKairosApi
+class FakeDemoModeMobileConnectionDataSourceKairos(kairos: KairosNetwork) :
+ DemoModeMobileConnectionDataSourceKairos {
+ override val mobileEvents = MutableEvents<FakeNetworkEventModel?>(kairos)
+}
+
+@ExperimentalKairosApi
+val DemoModeMobileConnectionDataSourceKairos.fake
+ get() = this as FakeDemoModeMobileConnectionDataSourceKairos
+
+@ExperimentalKairosApi
+val Kosmos.mobileRepositorySwitcherKairos:
+ MobileRepositorySwitcherKairos by ActivatedKairosFixture {
+ MobileRepositorySwitcherKairos(
+ realRepository = mobileConnectionsRepositoryKairosImpl,
+ demoRepositoryFactory = demoMobileConnectionsRepositoryKairosFactory,
+ demoModeController = demoModeController,
+ )
+}
+
+@ExperimentalKairosApi
+val Kosmos.demoMobileConnectionsRepositoryKairosFactory:
+ DemoMobileConnectionsRepositoryKairos.Factory by Fixture {
+ DemoMobileConnectionsRepositoryKairos.Factory {
+ DemoMobileConnectionsRepositoryKairos(
+ mobileDataSource = demoModeMobileConnectionDataSourceKairos,
+ wifiDataSource = wifiDataSource,
+ context = applicationContext,
+ logFactory = tableLogBufferFactory,
+ )
+ }
+}
+
+@ExperimentalKairosApi
+val Kosmos.mobileConnectionsRepositoryKairosImpl:
+ MobileConnectionsRepositoryKairosImpl by ActivatedKairosFixture {
+ MobileConnectionsRepositoryKairosImpl(
+ connectivityRepository = connectivityRepository,
+ subscriptionManager = subscriptionManager,
+ subscriptionManagerProxy = subscriptionManagerProxy,
+ telephonyManager = telephonyManager,
+ logger = mobileInputLogger,
+ tableLogger = summaryLogger,
+ mobileMappingsProxy = mobileMappingsProxy,
+ broadcastDispatcher = broadcastDispatcher,
+ context = applicationContext,
+ bgDispatcher = testDispatcher,
+ mainDispatcher = testDispatcher,
+ airplaneModeRepository = airplaneModeRepository,
+ wifiRepository = wifiRepository,
+ keyguardUpdateMonitor = keyguardUpdateMonitor,
+ dumpManager = dumpManager,
+ mobileRepoFactory = { mobileConnectionRepositoryKairosFactory },
+ )
+}
+
+val Kosmos.subscriptionManager: SubscriptionManager by mockFixture()
+val Kosmos.mobileInputLogger: MobileInputLogger by mockFixture()
+val Kosmos.summaryLogger: TableLogBuffer by Fixture { logcatTableLogBuffer(this, "summaryLogger") }
+
+@ExperimentalKairosApi
+val Kosmos.mobileConnectionRepositoryKairosFactory by Fixture {
+ MobileConnectionsRepositoryKairosImpl.ConnectionRepoFactory { subId ->
+ buildSpec { FakeMobileConnectionRepositoryKairos(subId, kairos, mock()) }
+ }
+}
+
+val Kosmos.subscriptionManagerProxy: SubscriptionManagerProxy by Fixture {
+ FakeSubscriptionManagerProxy()
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt
index a80238167b85..a391c44018f5 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/mobile/util/FakeSubscriptionManagerProxy.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 The Android Open Source Project
+ * 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.
@@ -23,7 +23,7 @@ import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
class FakeSubscriptionManagerProxy(
/** Set the default data subId to be returned in [getDefaultDataSubscriptionId] */
var defaultDataSubId: Int = INVALID_SUBSCRIPTION_ID,
- var activeSubscriptionInfo: SubscriptionInfo? = null
+ var activeSubscriptionInfo: SubscriptionInfo? = null,
) : SubscriptionManagerProxy {
override fun getDefaultDataSubscriptionId(): Int = defaultDataSubId
@@ -41,3 +41,6 @@ class FakeSubscriptionManagerProxy(
SubscriptionInfo.Builder().setId(subId).setEmbedded(isEmbedded).build()
}
}
+
+val SubscriptionManagerProxy.fake
+ get() = this as FakeSubscriptionManagerProxy
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt
index 00bfa994aabd..bb254a18efb7 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/shared/data/repository/ConnectivityRepositoryKosmos.kt
@@ -18,5 +18,5 @@ package com.android.systemui.statusbar.pipeline.shared.data.repository
import com.android.systemui.kosmos.Kosmos
-val Kosmos.connectivityRepository: ConnectivityRepository by
+var Kosmos.connectivityRepository: ConnectivityRepository by
Kosmos.Fixture { FakeConnectivityRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt
index e44061a718d5..f560c502d606 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/statusbar/pipeline/wifi/data/repository/WifiRepositoryKosmos.kt
@@ -19,4 +19,4 @@ package com.android.systemui.statusbar.pipeline.wifi.data.repository
import com.android.systemui.kosmos.Kosmos
val Kosmos.fakeWifiRepository: FakeWifiRepository by Kosmos.Fixture { FakeWifiRepository() }
-val Kosmos.wifiRepository: WifiRepository by Kosmos.Fixture { fakeWifiRepository }
+var Kosmos.wifiRepository: WifiRepository by Kosmos.Fixture { fakeWifiRepository }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt
new file mode 100644
index 000000000000..1638cb7772ac
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/MockitoKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.util.mockito
+
+import com.android.systemui.kosmos.Kosmos.Fixture
+import org.mockito.kotlin.mock
+
+inline fun <reified T> mockFixture(): Fixture<T> = Fixture { mock() }