diff options
author | 2025-01-29 15:46:12 -0500 | |
---|---|---|
committer | 2025-02-28 05:41:31 -0800 | |
commit | ec0712203ebcbbf7a12a9f4b84824c481b63bb0e (patch) | |
tree | 4edb1dbd83907a497f51907799ae0fcb632a4da8 | |
parent | 4dede63a151225636ea83c0e1a1b930a50f84a10 (diff) |
[kairos] Fork status bar mobile domain layer
Flag: com.android.systemui.status_bar_mobile_icon_kairos
Bug: 383172066
Test: atest
Change-Id: I0a80e5918212b37a12a10b2d5fbcf0d46dc1439a
5 files changed, 2759 insertions, 3 deletions
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/statusbar/pipeline/dagger/StatusBarPipelineModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt index 61c0055ada97..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 @@ -45,6 +45,7 @@ import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.Mobil 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 @@ -148,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 @@ -171,6 +169,18 @@ 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>, 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" + } +} |