diff options
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() } |