[SB refactor] Add connectivity tracking to the mobile pipeline

This CL tracks 2 bits from ConnectivityManager for mobile networks:
isConnected and isValidated. These bits are used to know whether or not
cellular networks are the default transport, and whether or not
connectivitymanager has validated that transport.

Test: manual
Test: atest packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/*
Bug: 240492102
Change-Id: I13890cdc59c198c1b5290ae92e7ec4ba491999e3
This commit is contained in:
Evan Laird
2022-10-26 17:59:25 -04:00
parent 93b945ce54
commit 09c154b291
12 changed files with 283 additions and 5 deletions

View File

@@ -0,0 +1,27 @@
/*
* 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.data.model
import android.net.NetworkCapabilities
/** Provides information about a mobile network connection */
data class MobileConnectivityModel(
/** Whether mobile is the connected transport see [NetworkCapabilities.TRANSPORT_CELLULAR] */
val isConnected: Boolean = false,
/** Whether the mobile transport is validated [NetworkCapabilities.NET_CAPABILITY_VALIDATED] */
val isValidated: Boolean = false,
)

View File

@@ -16,9 +16,16 @@
package com.android.systemui.statusbar.pipeline.mobile.data.repository
import android.annotation.SuppressLint
import android.content.Context
import android.content.IntentFilter
import android.database.ContentObserver
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
import android.provider.Settings
import android.provider.Settings.Global.MOBILE_DATA
import android.telephony.CarrierConfigManager
@@ -37,6 +44,7 @@ import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCall
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileConnectivityModel
import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger
import com.android.systemui.util.settings.GlobalSettings
import javax.inject.Inject
@@ -73,6 +81,9 @@ interface MobileConnectionsRepository {
/** Tracks [SubscriptionManager.getDefaultDataSubscriptionId] */
val defaultDataSubId: StateFlow<Int>
/** The current connectivity status for the default mobile network connection */
val defaultMobileNetworkConnectivity: StateFlow<MobileConnectivityModel>
/** Get or create a repository for the line of service for the given subscription ID */
fun getRepoForSubId(subId: Int): MobileConnectionRepository
@@ -86,6 +97,7 @@ interface MobileConnectionsRepository {
class MobileConnectionsRepositoryImpl
@Inject
constructor(
private val connectivityManager: ConnectivityManager,
private val subscriptionManager: SubscriptionManager,
private val telephonyManager: TelephonyManager,
private val logger: ConnectivityPipelineLogger,
@@ -212,6 +224,36 @@ constructor(
awaitClose { context.contentResolver.unregisterContentObserver(observer) }
}
@SuppressLint("MissingPermission")
override val defaultMobileNetworkConnectivity: StateFlow<MobileConnectivityModel> =
conflatedCallbackFlow {
val callback =
object : NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onLost(network: Network) {
// Send a disconnected model when lost. Maybe should create a sealed
// type or null here?
trySend(MobileConnectivityModel())
}
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities
) {
trySend(
MobileConnectivityModel(
isConnected = caps.hasTransport(TRANSPORT_CELLULAR),
isValidated = caps.hasCapability(NET_CAPABILITY_VALIDATED),
)
)
}
}
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}
.stateIn(scope, SharingStarted.WhileSubscribed(), MobileConnectivityModel())
private fun isValidSubId(subId: Int): Boolean {
subscriptionsFlow.value.forEach {
if (it.subscriptionId == subId) {

View File

@@ -26,7 +26,6 @@ import com.android.systemui.statusbar.pipeline.mobile.util.MobileMappingsProxy
import com.android.systemui.util.CarrierConfigTracker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -35,6 +34,9 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
interface MobileIconInteractor {
/** Only true if mobile is the default transport but is not validated, otherwise false */
val isDefaultConnectionFailed: StateFlow<Boolean>
// TODO(b/256839546): clarify naming of default vs active
/** True if we want to consider the data connection enabled */
val isDefaultDataEnabled: StateFlow<Boolean>
@@ -63,6 +65,7 @@ class MobileIconInteractorImpl(
defaultSubscriptionHasDataEnabled: StateFlow<Boolean>,
defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>,
defaultMobileIconGroup: StateFlow<MobileIconGroup>,
override val isDefaultConnectionFailed: StateFlow<Boolean>,
mobileMappingsProxy: MobileMappingsProxy,
connectionRepository: MobileConnectionRepository,
) : MobileIconInteractor {

View File

@@ -60,6 +60,8 @@ interface MobileIconsInteractor {
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>
/**
@@ -162,6 +164,21 @@ constructor(
.mapLatest { mobileMappingsProxy.getDefaultIcons(it) }
.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> =
mobileConnectionsRepo.defaultMobileNetworkConnectivity
.mapLatest { connectivityModel ->
if (!connectivityModel.isConnected) {
false
} else {
!connectivityModel.isValidated
}
}
.stateIn(scope, SharingStarted.WhileSubscribed(), false)
override val isUserSetup: StateFlow<Boolean> = userSetupRepo.isUserSetupFlow
/** Vends out new [MobileIconInteractor] for a particular subId */
@@ -171,6 +188,7 @@ constructor(
activeDataConnectionHasDataEnabled,
defaultMobileIconMapping,
defaultMobileIconGroup,
isDefaultConnectionFailed,
mobileMappingsProxy,
mobileConnectionsRepo.getRepoForSubId(subId),
)

View File

@@ -66,10 +66,12 @@ constructor(
/** The RAT icon (LTE, 3G, 5G, etc) to be displayed. Null if we shouldn't show anything */
val networkTypeIcon: Flow<Icon?> =
combine(iconInteractor.networkTypeIconGroup, iconInteractor.isDataEnabled) {
networkTypeIconGroup,
isDataEnabled ->
if (!isDataEnabled) {
combine(
iconInteractor.networkTypeIconGroup,
iconInteractor.isDataEnabled,
iconInteractor.isDefaultConnectionFailed
) { networkTypeIconGroup, isDataEnabled, isFailedConnection ->
if (!isDataEnabled || isFailedConnection) {
null
} else {
val desc =

View File

@@ -19,6 +19,7 @@ package com.android.systemui.statusbar.pipeline.mobile.data.repository
import android.telephony.SubscriptionInfo
import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
import com.android.settingslib.mobile.MobileMappings.Config
import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileConnectivityModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,6 +36,9 @@ class FakeMobileConnectionsRepository : MobileConnectionsRepository {
private val _defaultDataSubId = MutableStateFlow(INVALID_SUBSCRIPTION_ID)
override val defaultDataSubId = _defaultDataSubId
private val _mobileConnectivity = MutableStateFlow(MobileConnectivityModel())
override val defaultMobileNetworkConnectivity = _mobileConnectivity
private val subIdRepos = mutableMapOf<Int, MobileConnectionRepository>()
override fun getRepoForSubId(subId: Int): MobileConnectionRepository {
return subIdRepos[subId] ?: FakeMobileConnectionRepository().also { subIdRepos[subId] = it }
@@ -55,6 +59,10 @@ class FakeMobileConnectionsRepository : MobileConnectionsRepository {
_defaultDataSubId.value = id
}
fun setMobileConnectivity(model: MobileConnectivityModel) {
_mobileConnectivity.value = model
}
suspend fun triggerGlobalMobileDataSettingChangedEvent() {
_globalMobileDataSettingChangedEvent.emit(Unit)
}

View File

@@ -17,6 +17,11 @@
package com.android.systemui.statusbar.pipeline.mobile.data.repository
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
import android.provider.Settings
import android.telephony.SubscriptionInfo
import android.telephony.SubscriptionManager
@@ -26,6 +31,7 @@ import android.telephony.TelephonyManager
import androidx.test.filters.SmallTest
import com.android.internal.telephony.PhoneConstants
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileConnectivityModel
import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
@@ -54,6 +60,7 @@ import org.mockito.MockitoAnnotations
class MobileConnectionsRepositoryTest : SysuiTestCase() {
private lateinit var underTest: MobileConnectionsRepositoryImpl
@Mock private lateinit var connectivityManager: ConnectivityManager
@Mock private lateinit var subscriptionManager: SubscriptionManager
@Mock private lateinit var telephonyManager: TelephonyManager
@Mock private lateinit var logger: ConnectivityPipelineLogger
@@ -67,6 +74,7 @@ class MobileConnectionsRepositoryTest : SysuiTestCase() {
underTest =
MobileConnectionsRepositoryImpl(
connectivityManager,
subscriptionManager,
telephonyManager,
logger,
@@ -235,6 +243,29 @@ class MobileConnectionsRepositoryTest : SysuiTestCase() {
job.cancel()
}
@Test
fun mobileConnectivity_default() {
assertThat(underTest.defaultMobileNetworkConnectivity.value)
.isEqualTo(MobileConnectivityModel(isConnected = false, isValidated = false))
}
@Test
fun mobileConnectivity_isConnected_isValidated() =
runBlocking(IMMEDIATE) {
val caps = createCapabilities(connected = true, validated = true)
var latest: MobileConnectivityModel? = null
val job =
underTest.defaultMobileNetworkConnectivity.onEach { latest = it }.launchIn(this)
getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
assertThat(latest)
.isEqualTo(MobileConnectivityModel(isConnected = true, isValidated = true))
job.cancel()
}
@Test
fun globalMobileDataSettingsChangedEvent_producesOnSettingChange() =
runBlocking(IMMEDIATE) {
@@ -253,6 +284,69 @@ class MobileConnectionsRepositoryTest : SysuiTestCase() {
job.cancel()
}
@Test
fun mobileConnectivity_isConnected_isNotValidated() =
runBlocking(IMMEDIATE) {
val caps = createCapabilities(connected = true, validated = false)
var latest: MobileConnectivityModel? = null
val job =
underTest.defaultMobileNetworkConnectivity.onEach { latest = it }.launchIn(this)
getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
assertThat(latest)
.isEqualTo(MobileConnectivityModel(isConnected = true, isValidated = false))
job.cancel()
}
@Test
fun mobileConnectivity_isNotConnected_isNotValidated() =
runBlocking(IMMEDIATE) {
val caps = createCapabilities(connected = false, validated = false)
var latest: MobileConnectivityModel? = null
val job =
underTest.defaultMobileNetworkConnectivity.onEach { latest = it }.launchIn(this)
getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
assertThat(latest)
.isEqualTo(MobileConnectivityModel(isConnected = false, isValidated = false))
job.cancel()
}
/** In practice, I don't think this state can ever happen (!connected, validated) */
@Test
fun mobileConnectivity_isNotConnected_isValidated() =
runBlocking(IMMEDIATE) {
val caps = createCapabilities(connected = false, validated = true)
var latest: MobileConnectivityModel? = null
val job =
underTest.defaultMobileNetworkConnectivity.onEach { latest = it }.launchIn(this)
getDefaultNetworkCallback().onCapabilitiesChanged(NETWORK, caps)
assertThat(latest).isEqualTo(MobileConnectivityModel(false, true))
job.cancel()
}
private fun createCapabilities(connected: Boolean, validated: Boolean): NetworkCapabilities =
mock<NetworkCapabilities>().also {
whenever(it.hasTransport(TRANSPORT_CELLULAR)).thenReturn(connected)
whenever(it.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(validated)
}
private fun getDefaultNetworkCallback(): ConnectivityManager.NetworkCallback {
val callbackCaptor = argumentCaptor<ConnectivityManager.NetworkCallback>()
verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture())
return callbackCaptor.value!!
}
private fun getSubscriptionCallback(): SubscriptionManager.OnSubscriptionsChangedListener {
val callbackCaptor = argumentCaptor<SubscriptionManager.OnSubscriptionsChangedListener>()
verify(subscriptionManager)
@@ -281,5 +375,8 @@ class MobileConnectionsRepositoryTest : SysuiTestCase() {
private const val SUB_2_ID = 2
private val SUB_2 =
mock<SubscriptionInfo>().also { whenever(it.subscriptionId).thenReturn(SUB_2_ID) }
private const val NET_ID = 123
private val NETWORK = mock<Network>().apply { whenever(getNetId()).thenReturn(NET_ID) }
}
}

View File

@@ -28,6 +28,9 @@ class FakeMobileIconInteractor : MobileIconInteractor {
private val _isEmergencyOnly = MutableStateFlow(false)
override val isEmergencyOnly = _isEmergencyOnly
private val _isFailedConnection = MutableStateFlow(false)
override val isDefaultConnectionFailed = _isFailedConnection
private val _isDataEnabled = MutableStateFlow(true)
override val isDataEnabled = _isDataEnabled
@@ -56,6 +59,10 @@ class FakeMobileIconInteractor : MobileIconInteractor {
_isDefaultDataEnabled.value = disabled
}
fun setIsFailedConnection(failed: Boolean) {
_isFailedConnection.value = failed
}
fun setLevel(level: Int) {
_level.value = level
}

View File

@@ -45,6 +45,8 @@ class FakeMobileIconsInteractor(mobileMappings: MobileMappingsProxy) : MobileIco
FIVE_G_OVERRIDE_KEY to TelephonyIcons.NR_5G,
)
override val isDefaultConnectionFailed = MutableStateFlow(false)
private val _filteredSubscriptions = MutableStateFlow<List<SubscriptionInfo>>(listOf())
override val filteredSubscriptions = _filteredSubscriptions

View File

@@ -60,6 +60,7 @@ class MobileIconInteractorTest : SysuiTestCase() {
mobileIconsInteractor.activeDataConnectionHasDataEnabled,
mobileIconsInteractor.defaultMobileIconMapping,
mobileIconsInteractor.defaultMobileIconGroup,
mobileIconsInteractor.isDefaultConnectionFailed,
mobileMappingsProxy,
connectionRepository,
)
@@ -216,6 +217,20 @@ class MobileIconInteractorTest : SysuiTestCase() {
job.cancel()
}
@Test
fun test_isDefaultConnectionFailed_matchedParent() =
runBlocking(IMMEDIATE) {
val job = underTest.isDefaultConnectionFailed.launchIn(this)
mobileIconsInteractor.isDefaultConnectionFailed.value = false
assertThat(underTest.isDefaultConnectionFailed.value).isFalse()
mobileIconsInteractor.isDefaultConnectionFailed.value = true
assertThat(underTest.isDefaultConnectionFailed.value).isTrue()
job.cancel()
}
companion object {
private val IMMEDIATE = Dispatchers.Main.immediate

View File

@@ -20,6 +20,7 @@ import android.telephony.SubscriptionInfo
import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileConnectivityModel
import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionsRepository
import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeUserSetupRepository
@@ -215,6 +216,47 @@ class MobileIconsInteractorTest : SysuiTestCase() {
job.cancel()
}
@Test
fun failedConnection_connected_validated_notFailed() =
runBlocking(IMMEDIATE) {
var latest: Boolean? = null
val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this)
connectionsRepository.setMobileConnectivity(MobileConnectivityModel(true, true))
yield()
assertThat(latest).isFalse()
job.cancel()
}
@Test
fun failedConnection_notConnected_notValidated_notFailed() =
runBlocking(IMMEDIATE) {
var latest: Boolean? = null
val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this)
connectionsRepository.setMobileConnectivity(MobileConnectivityModel(false, false))
yield()
assertThat(latest).isFalse()
job.cancel()
}
@Test
fun failedConnection_connected_notValidated_failed() =
runBlocking(IMMEDIATE) {
var latest: Boolean? = null
val job = underTest.isDefaultConnectionFailed.onEach { latest = it }.launchIn(this)
connectionsRepository.setMobileConnectivity(MobileConnectivityModel(true, false))
yield()
assertThat(latest).isTrue()
job.cancel()
}
companion object {
private val IMMEDIATE = Dispatchers.Main.immediate

View File

@@ -47,6 +47,7 @@ class MobileIconViewModelTest : SysuiTestCase() {
interactor.apply {
setLevel(1)
setIsDefaultDataEnabled(true)
setIsFailedConnection(false)
setIconGroup(THREE_G)
setIsEmergencyOnly(false)
setNumberOfLevels(4)
@@ -111,6 +112,20 @@ class MobileIconViewModelTest : SysuiTestCase() {
job.cancel()
}
@Test
fun networkType_nullWhenFailedConnection() =
runBlocking(IMMEDIATE) {
interactor.setIconGroup(THREE_G)
interactor.setIsDataEnabled(true)
interactor.setIsFailedConnection(true)
var latest: Icon? = null
val job = underTest.networkTypeIcon.onEach { latest = it }.launchIn(this)
assertThat(latest).isNull()
job.cancel()
}
@Test
fun networkType_null_changeToDisabled() =
runBlocking(IMMEDIATE) {