diff options
14 files changed, 1902 insertions, 453 deletions
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 e7afc50dbc2c..c350c78913d3 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 @@ -25,7 +25,7 @@ import com.android.systemui.statusbar.pipeline.airplane.data.repository.Airplane import com.android.systemui.statusbar.pipeline.airplane.ui.viewmodel.AirplaneModeViewModel import com.android.systemui.statusbar.pipeline.airplane.ui.viewmodel.AirplaneModeViewModelImpl import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository -import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepositoryImpl +import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileRepositorySwitcher import com.android.systemui.statusbar.pipeline.mobile.data.repository.UserSetupRepository import com.android.systemui.statusbar.pipeline.mobile.data.repository.UserSetupRepositoryImpl import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor @@ -63,7 +63,7 @@ abstract class StatusBarPipelineModule { @Binds abstract fun mobileConnectionsRepository( - impl: MobileConnectionsRepositoryImpl + impl: MobileRepositorySwitcher ): MobileConnectionsRepository @Binds abstract fun userSetupRepository(impl: UserSetupRepositoryImpl): UserSetupRepository diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt index 581842bc2f57..f09456342f78 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt @@ -16,44 +16,13 @@ package com.android.systemui.statusbar.pipeline.mobile.data.repository -import android.content.Context -import android.database.ContentObserver -import android.provider.Settings.Global -import android.telephony.CellSignalStrength -import android.telephony.CellSignalStrengthCdma -import android.telephony.ServiceState -import android.telephony.SignalStrength import android.telephony.SubscriptionInfo import android.telephony.SubscriptionManager import android.telephony.TelephonyCallback -import android.telephony.TelephonyDisplayInfo -import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE import android.telephony.TelephonyManager -import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow -import com.android.systemui.dagger.qualifiers.Application -import com.android.systemui.dagger.qualifiers.Background -import com.android.systemui.statusbar.pipeline.mobile.data.model.DefaultNetworkType import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileSubscriptionModel -import com.android.systemui.statusbar.pipeline.mobile.data.model.OverrideNetworkType -import com.android.systemui.statusbar.pipeline.mobile.data.model.toDataConnectionType -import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger -import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger.Companion.logOutputChange -import com.android.systemui.util.settings.GlobalSettings -import java.lang.IllegalStateException -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn /** * Every mobile line of service can be identified via a [SubscriptionInfo] object. We set up a @@ -80,183 +49,3 @@ interface MobileConnectionRepository { */ val isDefaultDataSubscription: StateFlow<Boolean> } - -@Suppress("EXPERIMENTAL_IS_NOT_ENABLED") -@OptIn(ExperimentalCoroutinesApi::class) -class MobileConnectionRepositoryImpl( - private val context: Context, - private val subId: Int, - private val telephonyManager: TelephonyManager, - private val globalSettings: GlobalSettings, - defaultDataSubId: StateFlow<Int>, - globalMobileDataSettingChangedEvent: Flow<Unit>, - bgDispatcher: CoroutineDispatcher, - logger: ConnectivityPipelineLogger, - scope: CoroutineScope, -) : MobileConnectionRepository { - init { - if (telephonyManager.subscriptionId != subId) { - throw IllegalStateException( - "TelephonyManager should be created with subId($subId). " + - "Found ${telephonyManager.subscriptionId} instead." - ) - } - } - - private val telephonyCallbackEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1) - - override val subscriptionModelFlow: StateFlow<MobileSubscriptionModel> = run { - var state = MobileSubscriptionModel() - conflatedCallbackFlow { - // TODO (b/240569788): log all of these into the connectivity logger - val callback = - object : - TelephonyCallback(), - TelephonyCallback.ServiceStateListener, - TelephonyCallback.SignalStrengthsListener, - TelephonyCallback.DataConnectionStateListener, - TelephonyCallback.DataActivityListener, - TelephonyCallback.CarrierNetworkListener, - TelephonyCallback.DisplayInfoListener { - override fun onServiceStateChanged(serviceState: ServiceState) { - state = state.copy(isEmergencyOnly = serviceState.isEmergencyOnly) - trySend(state) - } - - override fun onSignalStrengthsChanged(signalStrength: SignalStrength) { - val cdmaLevel = - signalStrength - .getCellSignalStrengths(CellSignalStrengthCdma::class.java) - .let { strengths -> - if (!strengths.isEmpty()) { - strengths[0].level - } else { - CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN - } - } - - val primaryLevel = signalStrength.level - - state = - state.copy( - cdmaLevel = cdmaLevel, - primaryLevel = primaryLevel, - isGsm = signalStrength.isGsm, - ) - trySend(state) - } - - override fun onDataConnectionStateChanged( - dataState: Int, - networkType: Int - ) { - state = - state.copy(dataConnectionState = dataState.toDataConnectionType()) - trySend(state) - } - - override fun onDataActivity(direction: Int) { - state = state.copy(dataActivityDirection = direction) - trySend(state) - } - - override fun onCarrierNetworkChange(active: Boolean) { - state = state.copy(carrierNetworkChangeActive = active) - trySend(state) - } - - override fun onDisplayInfoChanged( - telephonyDisplayInfo: TelephonyDisplayInfo - ) { - val networkType = - if ( - telephonyDisplayInfo.overrideNetworkType == - OVERRIDE_NETWORK_TYPE_NONE - ) { - DefaultNetworkType(telephonyDisplayInfo.networkType) - } else { - OverrideNetworkType(telephonyDisplayInfo.overrideNetworkType) - } - state = state.copy(resolvedNetworkType = networkType) - trySend(state) - } - } - telephonyManager.registerTelephonyCallback(bgDispatcher.asExecutor(), callback) - awaitClose { telephonyManager.unregisterTelephonyCallback(callback) } - } - .onEach { telephonyCallbackEvent.tryEmit(Unit) } - .logOutputChange(logger, "MobileSubscriptionModel") - .stateIn(scope, SharingStarted.WhileSubscribed(), state) - } - - /** Produces whenever the mobile data setting changes for this subId */ - private val localMobileDataSettingChangedEvent: Flow<Unit> = conflatedCallbackFlow { - val observer = - object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - trySend(Unit) - } - } - - globalSettings.registerContentObserver( - globalSettings.getUriFor("${Global.MOBILE_DATA}$subId"), - /* notifyForDescendants */ true, - observer - ) - - awaitClose { context.contentResolver.unregisterContentObserver(observer) } - } - - /** - * There are a few cases where we will need to poll [TelephonyManager] so we can update some - * internal state where callbacks aren't provided. Any of those events should be merged into - * this flow, which can be used to trigger the polling. - */ - private val telephonyPollingEvent: Flow<Unit> = - merge( - telephonyCallbackEvent, - localMobileDataSettingChangedEvent, - globalMobileDataSettingChangedEvent, - ) - - override val dataEnabled: StateFlow<Boolean> = - telephonyPollingEvent - .mapLatest { dataConnectionAllowed() } - .stateIn(scope, SharingStarted.WhileSubscribed(), dataConnectionAllowed()) - - private fun dataConnectionAllowed(): Boolean = telephonyManager.isDataConnectionAllowed - - override val isDefaultDataSubscription: StateFlow<Boolean> = - defaultDataSubId - .mapLatest { it == subId } - .stateIn(scope, SharingStarted.WhileSubscribed(), defaultDataSubId.value == subId) - - class Factory - @Inject - constructor( - private val context: Context, - private val telephonyManager: TelephonyManager, - private val logger: ConnectivityPipelineLogger, - private val globalSettings: GlobalSettings, - @Background private val bgDispatcher: CoroutineDispatcher, - @Application private val scope: CoroutineScope, - ) { - fun build( - subId: Int, - defaultDataSubId: StateFlow<Int>, - globalMobileDataSettingChangedEvent: Flow<Unit>, - ): MobileConnectionRepository { - return MobileConnectionRepositoryImpl( - context, - subId, - telephonyManager.createForSubscriptionId(subId), - globalSettings, - defaultDataSubId, - globalMobileDataSettingChangedEvent, - bgDispatcher, - logger, - scope, - ) - } - } -} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepository.kt index c3c1f1403c60..14200f090c87 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepository.kt @@ -16,53 +16,14 @@ 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 import android.telephony.SubscriptionInfo import android.telephony.SubscriptionManager -import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID -import android.telephony.TelephonyCallback -import android.telephony.TelephonyCallback.ActiveDataSubscriptionIdListener -import android.telephony.TelephonyManager -import androidx.annotation.VisibleForTesting -import com.android.internal.telephony.PhoneConstants import com.android.settingslib.mobile.MobileMappings import com.android.settingslib.mobile.MobileMappings.Config -import com.android.systemui.broadcast.BroadcastDispatcher -import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow -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 -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext /** * Repo for monitoring the complete active subscription info list, to be consumed and filtered based @@ -90,202 +51,3 @@ interface MobileConnectionsRepository { /** Observe changes to the [Settings.Global.MOBILE_DATA] setting */ val globalMobileDataSettingChangedEvent: Flow<Unit> } - -@Suppress("EXPERIMENTAL_IS_NOT_ENABLED") -@OptIn(ExperimentalCoroutinesApi::class) -@SysUISingleton -class MobileConnectionsRepositoryImpl -@Inject -constructor( - private val connectivityManager: ConnectivityManager, - private val subscriptionManager: SubscriptionManager, - private val telephonyManager: TelephonyManager, - private val logger: ConnectivityPipelineLogger, - broadcastDispatcher: BroadcastDispatcher, - private val globalSettings: GlobalSettings, - private val context: Context, - @Background private val bgDispatcher: CoroutineDispatcher, - @Application private val scope: CoroutineScope, - private val mobileConnectionRepositoryFactory: MobileConnectionRepositoryImpl.Factory -) : MobileConnectionsRepository { - private val subIdRepositoryCache: MutableMap<Int, MobileConnectionRepository> = mutableMapOf() - - /** - * State flow that emits the set of mobile data subscriptions, each represented by its own - * [SubscriptionInfo]. We probably only need the [SubscriptionInfo.getSubscriptionId] of each - * info object, but for now we keep track of the infos themselves. - */ - override val subscriptionsFlow: StateFlow<List<SubscriptionInfo>> = - conflatedCallbackFlow { - val callback = - object : SubscriptionManager.OnSubscriptionsChangedListener() { - override fun onSubscriptionsChanged() { - trySend(Unit) - } - } - - subscriptionManager.addOnSubscriptionsChangedListener( - bgDispatcher.asExecutor(), - callback, - ) - - awaitClose { subscriptionManager.removeOnSubscriptionsChangedListener(callback) } - } - .mapLatest { fetchSubscriptionsList() } - .onEach { infos -> dropUnusedReposFromCache(infos) } - .stateIn(scope, started = SharingStarted.WhileSubscribed(), listOf()) - - /** StateFlow that keeps track of the current active mobile data subscription */ - override val activeMobileDataSubscriptionId: StateFlow<Int> = - conflatedCallbackFlow { - val callback = - object : TelephonyCallback(), ActiveDataSubscriptionIdListener { - override fun onActiveDataSubscriptionIdChanged(subId: Int) { - trySend(subId) - } - } - - telephonyManager.registerTelephonyCallback(bgDispatcher.asExecutor(), callback) - awaitClose { telephonyManager.unregisterTelephonyCallback(callback) } - } - .stateIn(scope, started = SharingStarted.WhileSubscribed(), INVALID_SUBSCRIPTION_ID) - - private val defaultDataSubIdChangeEvent: MutableSharedFlow<Unit> = - MutableSharedFlow(extraBufferCapacity = 1) - - override val defaultDataSubId: StateFlow<Int> = - broadcastDispatcher - .broadcastFlow( - IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED) - ) { intent, _ -> - intent.getIntExtra(PhoneConstants.SUBSCRIPTION_KEY, INVALID_SUBSCRIPTION_ID) - } - .distinctUntilChanged() - .onEach { defaultDataSubIdChangeEvent.tryEmit(Unit) } - .stateIn( - scope, - SharingStarted.WhileSubscribed(), - SubscriptionManager.getDefaultDataSubscriptionId() - ) - - private val carrierConfigChangedEvent = - broadcastDispatcher.broadcastFlow( - IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED) - ) - - /** - * [Config] is an object that tracks relevant configuration flags for a given subscription ID. - * In the case of [MobileMappings], it's hard-coded to check the default data subscription's - * config, so this will apply to every icon that we care about. - * - * Relevant bits in the config are things like - * [CarrierConfigManager.KEY_SHOW_4G_FOR_LTE_DATA_ICON_BOOL] - * - * This flow will produce whenever the default data subscription or the carrier config changes. - */ - override val defaultDataSubRatConfig: StateFlow<Config> = - merge(defaultDataSubIdChangeEvent, carrierConfigChangedEvent) - .mapLatest { Config.readConfig(context) } - .stateIn( - scope, - SharingStarted.WhileSubscribed(), - initialValue = Config.readConfig(context) - ) - - override fun getRepoForSubId(subId: Int): MobileConnectionRepository { - if (!isValidSubId(subId)) { - throw IllegalArgumentException( - "subscriptionId $subId is not in the list of valid subscriptions" - ) - } - - return subIdRepositoryCache[subId] - ?: createRepositoryForSubId(subId).also { subIdRepositoryCache[subId] = it } - } - - /** - * In single-SIM devices, the [MOBILE_DATA] setting is phone-wide. For multi-SIM, the individual - * connection repositories also observe the URI for [MOBILE_DATA] + subId. - */ - override val globalMobileDataSettingChangedEvent: Flow<Unit> = conflatedCallbackFlow { - val observer = - object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - trySend(Unit) - } - } - - globalSettings.registerContentObserver( - globalSettings.getUriFor(MOBILE_DATA), - true, - observer - ) - - 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) { - return true - } - } - - return false - } - - @VisibleForTesting fun getSubIdRepoCache() = subIdRepositoryCache - - private fun createRepositoryForSubId(subId: Int): MobileConnectionRepository { - return mobileConnectionRepositoryFactory.build( - subId, - defaultDataSubId, - globalMobileDataSettingChangedEvent, - ) - } - - private fun dropUnusedReposFromCache(newInfos: List<SubscriptionInfo>) { - // Remove any connection repository from the cache that isn't in the new set of IDs. They - // will get garbage collected once their subscribers go away - val currentValidSubscriptionIds = newInfos.map { it.subscriptionId } - - subIdRepositoryCache.keys.forEach { - if (!currentValidSubscriptionIds.contains(it)) { - subIdRepositoryCache.remove(it) - } - } - } - - private suspend fun fetchSubscriptionsList(): List<SubscriptionInfo> = - withContext(bgDispatcher) { subscriptionManager.completeActiveSubscriptionInfoList } -} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt new file mode 100644 index 000000000000..e21400525f00 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcher.kt @@ -0,0 +1,162 @@ +/* + * 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.repository + +import android.os.Bundle +import android.telephony.SubscriptionInfo +import androidx.annotation.VisibleForTesting +import com.android.settingslib.mobile.MobileMappings +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.demomode.DemoMode +import com.android.systemui.demomode.DemoModeController +import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileConnectivityModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepository +import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryImpl +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn + +/** + * A provider for the [MobileConnectionsRepository] interface that can choose between the Demo and + * Prod concrete implementations at runtime. It works by defining a base flow, [activeRepo], which + * switches based on the latest information from [DemoModeController], and switches every flow in + * the interface to point to the currently-active provider. This allows us to put the demo mode + * interface in its own repository, completely separate from the real version, while still using all + * of the prod implementations for the rest of the pipeline (interactors and onward). Looks + * something like this: + * + * ``` + * RealRepository + * │ + * ├──►RepositorySwitcher──►RealInteractor──►RealViewModel + * │ + * DemoRepository + * ``` + * + * NOTE: because the UI layer for mobile icons relies on a nested-repository structure, it is likely + * that we will have to drain the subscription list whenever demo mode changes. Otherwise if a real + * subscription list [1] is replaced with a demo subscription list [1], the view models will not see + * a change (due to `distinctUntilChanged`) and will not refresh their data providers to the demo + * implementation. + */ +@Suppress("EXPERIMENTAL_IS_NOT_ENABLED") +@OptIn(ExperimentalCoroutinesApi::class) +class MobileRepositorySwitcher +@Inject +constructor( + @Application scope: CoroutineScope, + val realRepository: MobileConnectionsRepositoryImpl, + val demoMobileConnectionsRepository: DemoMobileConnectionsRepository, + demoModeController: DemoModeController, +) : MobileConnectionsRepository { + + val isDemoMode: StateFlow<Boolean> = + conflatedCallbackFlow { + val callback = + object : DemoMode { + override fun dispatchDemoCommand(command: String?, args: Bundle?) { + // Nothing, we just care about on/off + } + + override fun onDemoModeStarted() { + demoMobileConnectionsRepository.startProcessingCommands() + trySend(true) + } + + override fun onDemoModeFinished() { + demoMobileConnectionsRepository.stopProcessingCommands() + trySend(false) + } + } + + demoModeController.addCallback(callback) + awaitClose { demoModeController.removeCallback(callback) } + } + .stateIn(scope, SharingStarted.WhileSubscribed(), demoModeController.isInDemoMode) + + // Convenient definition flow for the currently active repo (based on demo mode or not) + @VisibleForTesting + internal val activeRepo: StateFlow<MobileConnectionsRepository> = + isDemoMode + .mapLatest { demoMode -> + if (demoMode) { + demoMobileConnectionsRepository + } else { + realRepository + } + } + .stateIn(scope, SharingStarted.WhileSubscribed(), realRepository) + + override val subscriptionsFlow: StateFlow<List<SubscriptionInfo>> = + activeRepo + .flatMapLatest { it.subscriptionsFlow } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + realRepository.subscriptionsFlow.value + ) + + override val activeMobileDataSubscriptionId: StateFlow<Int> = + activeRepo + .flatMapLatest { it.activeMobileDataSubscriptionId } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + realRepository.activeMobileDataSubscriptionId.value + ) + + override val defaultDataSubRatConfig: StateFlow<MobileMappings.Config> = + activeRepo + .flatMapLatest { it.defaultDataSubRatConfig } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + realRepository.defaultDataSubRatConfig.value + ) + + override val defaultDataSubId: StateFlow<Int> = + activeRepo + .flatMapLatest { it.defaultDataSubId } + .stateIn(scope, SharingStarted.WhileSubscribed(), realRepository.defaultDataSubId.value) + + override val defaultMobileNetworkConnectivity: StateFlow<MobileConnectivityModel> = + activeRepo + .flatMapLatest { it.defaultMobileNetworkConnectivity } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + realRepository.defaultMobileNetworkConnectivity.value + ) + + override val globalMobileDataSettingChangedEvent: Flow<Unit> = + activeRepo.flatMapLatest { it.globalMobileDataSettingChangedEvent } + + override fun getRepoForSubId(subId: Int): MobileConnectionRepository { + if (isDemoMode.value) { + return demoMobileConnectionsRepository.getRepoForSubId(subId) + } + return realRepository.getRepoForSubId(subId) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt new file mode 100644 index 000000000000..5f2feb26b739 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt @@ -0,0 +1,277 @@ +/* + * 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.repository.demo + +import android.content.Context +import android.telephony.Annotation +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID +import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED +import android.telephony.TelephonyManager.NETWORK_TYPE_GSM +import android.telephony.TelephonyManager.NETWORK_TYPE_LTE +import android.telephony.TelephonyManager.NETWORK_TYPE_NR +import android.telephony.TelephonyManager.NETWORK_TYPE_UMTS +import android.telephony.TelephonyManager.NETWORK_TYPE_UNKNOWN +import android.util.Log +import com.android.settingslib.SignalIcon +import com.android.settingslib.mobile.MobileMappings +import com.android.settingslib.mobile.TelephonyIcons +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState +import com.android.systemui.statusbar.pipeline.mobile.data.model.DefaultNetworkType +import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileConnectivityModel +import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileSubscriptionModel +import com.android.systemui.statusbar.pipeline.mobile.data.model.OverrideNetworkType +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.data.repository.MobileConnectionsRepository +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** This repository vends out data based on demo mode commands */ +@OptIn(ExperimentalCoroutinesApi::class) +class DemoMobileConnectionsRepository +@Inject +constructor( + private val dataSource: DemoModeMobileConnectionDataSource, + @Application private val scope: CoroutineScope, + context: Context, +) : MobileConnectionsRepository { + + private var demoCommandJob: Job? = null + + private val connectionRepoCache = mutableMapOf<Int, DemoMobileConnectionRepository>() + private val subscriptionInfoCache = mutableMapOf<Int, SubscriptionInfo>() + val demoModeFinishedEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1) + + private val _subscriptions = MutableStateFlow<List<SubscriptionInfo>>(listOf()) + override val subscriptionsFlow = + _subscriptions + .onEach { infos -> dropUnusedReposFromCache(infos) } + .stateIn(scope, SharingStarted.WhileSubscribed(), _subscriptions.value) + + private fun dropUnusedReposFromCache(newInfos: List<SubscriptionInfo>) { + // Remove any connection repository from the cache that isn't in the new set of IDs. They + // will get garbage collected once their subscribers go away + val currentValidSubscriptionIds = newInfos.map { it.subscriptionId } + + connectionRepoCache.keys.forEach { + if (!currentValidSubscriptionIds.contains(it)) { + connectionRepoCache.remove(it) + } + } + } + + private fun maybeCreateSubscription(subId: Int) { + if (!subscriptionInfoCache.containsKey(subId)) { + createSubscriptionForSubId(subId, subId).also { subscriptionInfoCache[subId] = it } + + _subscriptions.value = subscriptionInfoCache.values.toList() + } + } + + /** Mimics the old NetworkControllerImpl for now */ + private fun createSubscriptionForSubId(subId: Int, slotIndex: Int): SubscriptionInfo { + return SubscriptionInfo( + subId, + "", + slotIndex, + "", + "", + 0, + 0, + "", + 0, + null, + null, + null, + "", + false, + null, + null, + ) + } + + // TODO(b/261029387): add a command for this value + override val activeMobileDataSubscriptionId = + subscriptionsFlow + .mapLatest { infos -> + // For now, active is just the first in the list + infos.firstOrNull()?.subscriptionId ?: INVALID_SUBSCRIPTION_ID + } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + subscriptionsFlow.value.firstOrNull()?.subscriptionId ?: INVALID_SUBSCRIPTION_ID + ) + + /** Demo mode doesn't currently support modifications to the mobile mappings */ + override val defaultDataSubRatConfig = + MutableStateFlow(MobileMappings.Config.readConfig(context)) + + // TODO(b/261029387): add a command for this value + override val defaultDataSubId = + activeMobileDataSubscriptionId.stateIn( + scope, + SharingStarted.WhileSubscribed(), + INVALID_SUBSCRIPTION_ID + ) + + // TODO(b/261029387): not yet supported + override val defaultMobileNetworkConnectivity = MutableStateFlow(MobileConnectivityModel()) + + override fun getRepoForSubId(subId: Int): DemoMobileConnectionRepository { + return connectionRepoCache[subId] + ?: DemoMobileConnectionRepository(subId).also { connectionRepoCache[subId] = it } + } + + override val globalMobileDataSettingChangedEvent = MutableStateFlow(Unit) + + fun startProcessingCommands() { + demoCommandJob = + scope.launch { + dataSource.mobileEvents.filterNotNull().collect { event -> processEvent(event) } + } + } + + fun stopProcessingCommands() { + demoCommandJob?.cancel() + _subscriptions.value = listOf() + connectionRepoCache.clear() + subscriptionInfoCache.clear() + } + + private fun processEvent(event: FakeNetworkEventModel) { + when (event) { + is Mobile -> { + processEnabledMobileState(event) + } + is MobileDisabled -> { + processDisabledMobileState(event) + } + } + } + + private fun processEnabledMobileState(state: Mobile) { + // get or create the connection repo, and set its values + val subId = state.subId ?: DEFAULT_SUB_ID + maybeCreateSubscription(subId) + + val connection = getRepoForSubId(subId) + // This is always true here, because we split out disabled states at the data-source level + connection.dataEnabled.value = true + connection.isDefaultDataSubscription.value = state.dataType != null + + connection.subscriptionModelFlow.value = state.toMobileSubscriptionModel() + } + + private fun processDisabledMobileState(state: MobileDisabled) { + if (_subscriptions.value.isEmpty()) { + // Nothing to do here + return + } + + val subId = + state.subId + ?: run { + // For sake of usability, we can allow for no subId arg if there is only one + // subscription + if (_subscriptions.value.size > 1) { + Log.d( + TAG, + "processDisabledMobileState: Unable to infer subscription to " + + "disable. Specify subId using '-e slot <subId>'" + + "Known subIds: [${subIdsString()}]" + ) + return + } + + // Use the only existing subscription as our arg, since there is only one + _subscriptions.value[0].subscriptionId + } + + removeSubscription(subId) + } + + private fun removeSubscription(subId: Int) { + val currentSubscriptions = _subscriptions.value + subscriptionInfoCache.remove(subId) + _subscriptions.value = currentSubscriptions.filter { it.subscriptionId != subId } + } + + private fun subIdsString(): String = + _subscriptions.value.joinToString(",") { it.subscriptionId.toString() } + + companion object { + private const val TAG = "DemoMobileConnectionsRepo" + + private const val DEFAULT_SUB_ID = 1 + } +} + +private fun Mobile.toMobileSubscriptionModel(): MobileSubscriptionModel { + return MobileSubscriptionModel( + isEmergencyOnly = false, // TODO(b/261029387): not yet supported + isGsm = false, // TODO(b/261029387): not yet supported + cdmaLevel = level ?: 0, + primaryLevel = level ?: 0, + dataConnectionState = DataConnectionState.Connected, // TODO(b/261029387): not yet supported + dataActivityDirection = activity, + carrierNetworkChangeActive = carrierNetworkChange, + // TODO(b/261185097): once mobile mappings can be mocked at this layer, we can build our + // own demo map + resolvedNetworkType = dataType.toResolvedNetworkType() + ) +} + +@Annotation.NetworkType +private fun SignalIcon.MobileIconGroup?.toNetworkType(): Int = + when (this) { + TelephonyIcons.THREE_G -> NETWORK_TYPE_GSM + TelephonyIcons.LTE -> NETWORK_TYPE_LTE + TelephonyIcons.FOUR_G -> NETWORK_TYPE_UMTS + TelephonyIcons.NR_5G -> NETWORK_TYPE_NR + TelephonyIcons.NR_5G_PLUS -> OVERRIDE_NETWORK_TYPE_NR_ADVANCED + else -> NETWORK_TYPE_UNKNOWN + } + +private fun SignalIcon.MobileIconGroup?.toResolvedNetworkType(): ResolvedNetworkType = + when (this) { + TelephonyIcons.NR_5G_PLUS -> OverrideNetworkType(toNetworkType()) + else -> DefaultNetworkType(toNetworkType()) + } + +class DemoMobileConnectionRepository(val subId: Int) : MobileConnectionRepository { + override val subscriptionModelFlow = MutableStateFlow(MobileSubscriptionModel()) + + override val dataEnabled = MutableStateFlow(true) + + override val isDefaultDataSubscription = MutableStateFlow(true) +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSource.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSource.kt new file mode 100644 index 000000000000..da55787e8fed --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoModeMobileConnectionDataSource.kt @@ -0,0 +1,141 @@ +/* + * 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.repository.demo + +import android.os.Bundle +import android.telephony.Annotation.DataActivityType +import android.telephony.TelephonyManager.DATA_ACTIVITY_IN +import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT +import android.telephony.TelephonyManager.DATA_ACTIVITY_NONE +import android.telephony.TelephonyManager.DATA_ACTIVITY_OUT +import com.android.settingslib.SignalIcon.MobileIconGroup +import com.android.settingslib.mobile.TelephonyIcons +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.demomode.DemoMode +import com.android.systemui.demomode.DemoMode.COMMAND_NETWORK +import com.android.systemui.demomode.DemoModeController +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.Mobile +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn + +/** + * Data source that can map from demo mode commands to inputs into the + * [DemoMobileConnectionsRepository]'s flows + */ +@SysUISingleton +class DemoModeMobileConnectionDataSource +@Inject +constructor( + demoModeController: DemoModeController, + @Application scope: CoroutineScope, +) { + private val demoCommandStream: Flow<Bundle> = conflatedCallbackFlow { + val callback = + object : DemoMode { + override fun demoCommands(): List<String> = listOf(COMMAND_NETWORK) + + override fun dispatchDemoCommand(command: String, args: Bundle) { + trySend(args) + } + + override fun onDemoModeFinished() { + // Handled elsewhere + } + + override fun onDemoModeStarted() { + // Handled elsewhere + } + } + + demoModeController.addCallback(callback) + awaitClose { demoModeController.removeCallback(callback) } + } + + // If the args contains "mobile", then all of the args are relevant. It's just the way demo mode + // commands work and it's a little silly + private val _mobileCommands = demoCommandStream.map { args -> args.toMobileEvent() } + val mobileEvents = _mobileCommands.shareIn(scope, SharingStarted.WhileSubscribed()) + + private fun Bundle.toMobileEvent(): FakeNetworkEventModel? { + val mobile = getString("mobile") ?: return null + return if (mobile == "show") { + activeMobileEvent() + } else { + MobileDisabled(subId = getString("slot")?.toInt()) + } + } + + /** Parse a valid mobile command string into a network event */ + private fun Bundle.activeMobileEvent(): Mobile { + // There are many key/value pairs supported by mobile demo mode. Bear with me here + val level = getString("level")?.toInt() + val dataType = getString("datatype")?.toDataType() + val slot = getString("slot")?.toInt() + val carrierId = getString("carrierid")?.toInt() + val inflateStrength = getString("inflate")?.toBoolean() + val activity = getString("activity")?.toActivity() + val carrierNetworkChange = getString("carriernetworkchange") == "show" + + return Mobile( + level = level, + dataType = dataType, + subId = slot, + carrierId = carrierId, + inflateStrength = inflateStrength, + activity = activity, + carrierNetworkChange = carrierNetworkChange, + ) + } +} + +private fun String.toDataType(): MobileIconGroup = + when (this) { + "1x" -> TelephonyIcons.ONE_X + "3g" -> TelephonyIcons.THREE_G + "4g" -> TelephonyIcons.FOUR_G + "4g+" -> TelephonyIcons.FOUR_G_PLUS + "5g" -> TelephonyIcons.NR_5G + "5ge" -> TelephonyIcons.LTE_CA_5G_E + "5g+" -> TelephonyIcons.NR_5G_PLUS + "e" -> TelephonyIcons.E + "g" -> TelephonyIcons.G + "h" -> TelephonyIcons.H + "h+" -> TelephonyIcons.H_PLUS + "lte" -> TelephonyIcons.LTE + "lte+" -> TelephonyIcons.LTE_PLUS + "dis" -> TelephonyIcons.DATA_DISABLED + "not" -> TelephonyIcons.NOT_DEFAULT_DATA + else -> TelephonyIcons.UNKNOWN + } + +@DataActivityType +private fun String.toActivity(): Int = + when (this) { + "inout" -> DATA_ACTIVITY_INOUT + "in" -> DATA_ACTIVITY_IN + "out" -> DATA_ACTIVITY_OUT + else -> DATA_ACTIVITY_NONE + } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt new file mode 100644 index 000000000000..3f3acafd2d1c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/model/FakeNetworkEventModel.kt @@ -0,0 +1,43 @@ +/* + * 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.repository.demo.model + +import android.telephony.Annotation.DataActivityType +import com.android.settingslib.SignalIcon + +/** + * Model for the demo commands, ported from [NetworkControllerImpl] + * + * Nullable fields represent optional command line arguments + */ +sealed interface FakeNetworkEventModel { + data class Mobile( + val level: Int?, + val dataType: SignalIcon.MobileIconGroup?, + // Null means the default (chosen by the repository) + val subId: Int?, + val carrierId: Int?, + val inflateStrength: Boolean?, + @DataActivityType val activity: Int?, + val carrierNetworkChange: Boolean, + ) : FakeNetworkEventModel + + data class MobileDisabled( + // Null means the default (chosen by the repository) + val subId: Int? + ) : FakeNetworkEventModel +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt new file mode 100644 index 000000000000..4c1cf4a3ed08 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt @@ -0,0 +1,235 @@ +/* + * 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.repository.prod + +import android.content.Context +import android.database.ContentObserver +import android.provider.Settings.Global +import android.telephony.CellSignalStrength +import android.telephony.CellSignalStrengthCdma +import android.telephony.ServiceState +import android.telephony.SignalStrength +import android.telephony.TelephonyCallback +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE +import android.telephony.TelephonyManager +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.statusbar.pipeline.mobile.data.model.DefaultNetworkType +import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileSubscriptionModel +import com.android.systemui.statusbar.pipeline.mobile.data.model.OverrideNetworkType +import com.android.systemui.statusbar.pipeline.mobile.data.model.toDataConnectionType +import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository +import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger +import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger.Companion.logOutputChange +import com.android.systemui.util.settings.GlobalSettings +import java.lang.IllegalStateException +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn + +@Suppress("EXPERIMENTAL_IS_NOT_ENABLED") +@OptIn(ExperimentalCoroutinesApi::class) +class MobileConnectionRepositoryImpl( + private val context: Context, + private val subId: Int, + private val telephonyManager: TelephonyManager, + private val globalSettings: GlobalSettings, + defaultDataSubId: StateFlow<Int>, + globalMobileDataSettingChangedEvent: Flow<Unit>, + bgDispatcher: CoroutineDispatcher, + logger: ConnectivityPipelineLogger, + scope: CoroutineScope, +) : MobileConnectionRepository { + init { + if (telephonyManager.subscriptionId != subId) { + throw IllegalStateException( + "TelephonyManager should be created with subId($subId). " + + "Found ${telephonyManager.subscriptionId} instead." + ) + } + } + + private val telephonyCallbackEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1) + + override val subscriptionModelFlow: StateFlow<MobileSubscriptionModel> = run { + var state = MobileSubscriptionModel() + conflatedCallbackFlow { + // TODO (b/240569788): log all of these into the connectivity logger + val callback = + object : + TelephonyCallback(), + TelephonyCallback.ServiceStateListener, + TelephonyCallback.SignalStrengthsListener, + TelephonyCallback.DataConnectionStateListener, + TelephonyCallback.DataActivityListener, + TelephonyCallback.CarrierNetworkListener, + TelephonyCallback.DisplayInfoListener { + override fun onServiceStateChanged(serviceState: ServiceState) { + state = state.copy(isEmergencyOnly = serviceState.isEmergencyOnly) + trySend(state) + } + + override fun onSignalStrengthsChanged(signalStrength: SignalStrength) { + val cdmaLevel = + signalStrength + .getCellSignalStrengths(CellSignalStrengthCdma::class.java) + .let { strengths -> + if (!strengths.isEmpty()) { + strengths[0].level + } else { + CellSignalStrength.SIGNAL_STRENGTH_NONE_OR_UNKNOWN + } + } + + val primaryLevel = signalStrength.level + + state = + state.copy( + cdmaLevel = cdmaLevel, + primaryLevel = primaryLevel, + isGsm = signalStrength.isGsm, + ) + trySend(state) + } + + override fun onDataConnectionStateChanged( + dataState: Int, + networkType: Int + ) { + state = + state.copy(dataConnectionState = dataState.toDataConnectionType()) + trySend(state) + } + + override fun onDataActivity(direction: Int) { + state = state.copy(dataActivityDirection = direction) + trySend(state) + } + + override fun onCarrierNetworkChange(active: Boolean) { + state = state.copy(carrierNetworkChangeActive = active) + trySend(state) + } + + override fun onDisplayInfoChanged( + telephonyDisplayInfo: TelephonyDisplayInfo + ) { + val networkType = + if ( + telephonyDisplayInfo.overrideNetworkType == + OVERRIDE_NETWORK_TYPE_NONE + ) { + DefaultNetworkType(telephonyDisplayInfo.networkType) + } else { + OverrideNetworkType(telephonyDisplayInfo.overrideNetworkType) + } + state = state.copy(resolvedNetworkType = networkType) + trySend(state) + } + } + telephonyManager.registerTelephonyCallback(bgDispatcher.asExecutor(), callback) + awaitClose { telephonyManager.unregisterTelephonyCallback(callback) } + } + .onEach { telephonyCallbackEvent.tryEmit(Unit) } + .logOutputChange(logger, "MobileSubscriptionModel") + .stateIn(scope, SharingStarted.WhileSubscribed(), state) + } + + /** Produces whenever the mobile data setting changes for this subId */ + private val localMobileDataSettingChangedEvent: Flow<Unit> = conflatedCallbackFlow { + val observer = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + globalSettings.registerContentObserver( + globalSettings.getUriFor("${Global.MOBILE_DATA}$subId"), + /* notifyForDescendants */ true, + observer + ) + + awaitClose { context.contentResolver.unregisterContentObserver(observer) } + } + + /** + * There are a few cases where we will need to poll [TelephonyManager] so we can update some + * internal state where callbacks aren't provided. Any of those events should be merged into + * this flow, which can be used to trigger the polling. + */ + private val telephonyPollingEvent: Flow<Unit> = + merge( + telephonyCallbackEvent, + localMobileDataSettingChangedEvent, + globalMobileDataSettingChangedEvent, + ) + + override val dataEnabled: StateFlow<Boolean> = + telephonyPollingEvent + .mapLatest { dataConnectionAllowed() } + .stateIn(scope, SharingStarted.WhileSubscribed(), dataConnectionAllowed()) + + private fun dataConnectionAllowed(): Boolean = telephonyManager.isDataConnectionAllowed + + override val isDefaultDataSubscription: StateFlow<Boolean> = + defaultDataSubId + .mapLatest { it == subId } + .stateIn(scope, SharingStarted.WhileSubscribed(), defaultDataSubId.value == subId) + + class Factory + @Inject + constructor( + private val context: Context, + private val telephonyManager: TelephonyManager, + private val logger: ConnectivityPipelineLogger, + private val globalSettings: GlobalSettings, + @Background private val bgDispatcher: CoroutineDispatcher, + @Application private val scope: CoroutineScope, + ) { + fun build( + subId: Int, + defaultDataSubId: StateFlow<Int>, + globalMobileDataSettingChangedEvent: Flow<Unit>, + ): MobileConnectionRepository { + return MobileConnectionRepositoryImpl( + context, + subId, + telephonyManager.createForSubscriptionId(subId), + globalSettings, + defaultDataSubId, + globalMobileDataSettingChangedEvent, + bgDispatcher, + logger, + scope, + ) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt new file mode 100644 index 000000000000..08d6010f0d99 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt @@ -0,0 +1,265 @@ +/* + * 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.repository.prod + +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.Global.MOBILE_DATA +import android.telephony.CarrierConfigManager +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID +import android.telephony.TelephonyCallback +import android.telephony.TelephonyCallback.ActiveDataSubscriptionIdListener +import android.telephony.TelephonyManager +import androidx.annotation.VisibleForTesting +import com.android.internal.telephony.PhoneConstants +import com.android.settingslib.mobile.MobileMappings +import com.android.settingslib.mobile.MobileMappings.Config +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +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.mobile.data.repository.MobileConnectionRepository +import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository +import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger +import com.android.systemui.util.settings.GlobalSettings +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +@Suppress("EXPERIMENTAL_IS_NOT_ENABLED") +@OptIn(ExperimentalCoroutinesApi::class) +@SysUISingleton +class MobileConnectionsRepositoryImpl +@Inject +constructor( + private val connectivityManager: ConnectivityManager, + private val subscriptionManager: SubscriptionManager, + private val telephonyManager: TelephonyManager, + private val logger: ConnectivityPipelineLogger, + broadcastDispatcher: BroadcastDispatcher, + private val globalSettings: GlobalSettings, + private val context: Context, + @Background private val bgDispatcher: CoroutineDispatcher, + @Application private val scope: CoroutineScope, + private val mobileConnectionRepositoryFactory: MobileConnectionRepositoryImpl.Factory +) : MobileConnectionsRepository { + private val subIdRepositoryCache: MutableMap<Int, MobileConnectionRepository> = mutableMapOf() + + /** + * State flow that emits the set of mobile data subscriptions, each represented by its own + * [SubscriptionInfo]. We probably only need the [SubscriptionInfo.getSubscriptionId] of each + * info object, but for now we keep track of the infos themselves. + */ + override val subscriptionsFlow: StateFlow<List<SubscriptionInfo>> = + conflatedCallbackFlow { + val callback = + object : SubscriptionManager.OnSubscriptionsChangedListener() { + override fun onSubscriptionsChanged() { + trySend(Unit) + } + } + + subscriptionManager.addOnSubscriptionsChangedListener( + bgDispatcher.asExecutor(), + callback, + ) + + awaitClose { subscriptionManager.removeOnSubscriptionsChangedListener(callback) } + } + .mapLatest { fetchSubscriptionsList() } + .onEach { infos -> dropUnusedReposFromCache(infos) } + .stateIn(scope, started = SharingStarted.WhileSubscribed(), listOf()) + + /** StateFlow that keeps track of the current active mobile data subscription */ + override val activeMobileDataSubscriptionId: StateFlow<Int> = + conflatedCallbackFlow { + val callback = + object : TelephonyCallback(), ActiveDataSubscriptionIdListener { + override fun onActiveDataSubscriptionIdChanged(subId: Int) { + trySend(subId) + } + } + + telephonyManager.registerTelephonyCallback(bgDispatcher.asExecutor(), callback) + awaitClose { telephonyManager.unregisterTelephonyCallback(callback) } + } + .stateIn(scope, started = SharingStarted.WhileSubscribed(), INVALID_SUBSCRIPTION_ID) + + private val defaultDataSubIdChangeEvent: MutableSharedFlow<Unit> = + MutableSharedFlow(extraBufferCapacity = 1) + + override val defaultDataSubId: StateFlow<Int> = + broadcastDispatcher + .broadcastFlow( + IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED) + ) { intent, _ -> + intent.getIntExtra(PhoneConstants.SUBSCRIPTION_KEY, INVALID_SUBSCRIPTION_ID) + } + .distinctUntilChanged() + .onEach { defaultDataSubIdChangeEvent.tryEmit(Unit) } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + SubscriptionManager.getDefaultDataSubscriptionId() + ) + + private val carrierConfigChangedEvent = + broadcastDispatcher.broadcastFlow( + IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED) + ) + + /** + * [Config] is an object that tracks relevant configuration flags for a given subscription ID. + * In the case of [MobileMappings], it's hard-coded to check the default data subscription's + * config, so this will apply to every icon that we care about. + * + * Relevant bits in the config are things like + * [CarrierConfigManager.KEY_SHOW_4G_FOR_LTE_DATA_ICON_BOOL] + * + * This flow will produce whenever the default data subscription or the carrier config changes. + */ + override val defaultDataSubRatConfig: StateFlow<Config> = + merge(defaultDataSubIdChangeEvent, carrierConfigChangedEvent) + .mapLatest { Config.readConfig(context) } + .stateIn( + scope, + SharingStarted.WhileSubscribed(), + initialValue = Config.readConfig(context) + ) + + override fun getRepoForSubId(subId: Int): MobileConnectionRepository { + if (!isValidSubId(subId)) { + throw IllegalArgumentException( + "subscriptionId $subId is not in the list of valid subscriptions" + ) + } + + return subIdRepositoryCache[subId] + ?: createRepositoryForSubId(subId).also { subIdRepositoryCache[subId] = it } + } + + /** + * In single-SIM devices, the [MOBILE_DATA] setting is phone-wide. For multi-SIM, the individual + * connection repositories also observe the URI for [MOBILE_DATA] + subId. + */ + override val globalMobileDataSettingChangedEvent: Flow<Unit> = conflatedCallbackFlow { + val observer = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + globalSettings.registerContentObserver( + globalSettings.getUriFor(MOBILE_DATA), + true, + observer + ) + + 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) { + return true + } + } + + return false + } + + @VisibleForTesting fun getSubIdRepoCache() = subIdRepositoryCache + + private fun createRepositoryForSubId(subId: Int): MobileConnectionRepository { + return mobileConnectionRepositoryFactory.build( + subId, + defaultDataSubId, + globalMobileDataSettingChangedEvent, + ) + } + + private fun dropUnusedReposFromCache(newInfos: List<SubscriptionInfo>) { + // Remove any connection repository from the cache that isn't in the new set of IDs. They + // will get garbage collected once their subscribers go away + val currentValidSubscriptionIds = newInfos.map { it.subscriptionId } + + subIdRepositoryCache.keys.forEach { + if (!currentValidSubscriptionIds.contains(it)) { + subIdRepositoryCache.remove(it) + } + } + } + + private suspend fun fetchSubscriptionsList(): List<SubscriptionInfo> = + withContext(bgDispatcher) { subscriptionManager.completeActiveSubscriptionInfoList } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt new file mode 100644 index 000000000000..96a280a296ed --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt @@ -0,0 +1,219 @@ +/* + * 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.repository + +import android.net.ConnectivityManager +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.demomode.DemoMode +import com.android.systemui.demomode.DemoModeController +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoMobileConnectionsRepository +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.DemoModeMobileConnectionDataSource +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.validMobileEvent +import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.MobileConnectionsRepositoryImpl +import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.kotlinArgumentCaptor +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.FakeSettings +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +/** + * The switcher acts as a dispatcher to either the `prod` or `demo` versions of the repository + * interface it's switching on. These tests just need to verify that the entire interface properly + * switches over when the value of `demoMode` changes + */ +@Suppress("EXPERIMENTAL_IS_NOT_ENABLED") +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class MobileRepositorySwitcherTest : SysuiTestCase() { + private lateinit var underTest: MobileRepositorySwitcher + private lateinit var realRepo: MobileConnectionsRepositoryImpl + private lateinit var demoRepo: DemoMobileConnectionsRepository + private lateinit var mockDataSource: DemoModeMobileConnectionDataSource + + @Mock private lateinit var connectivityManager: ConnectivityManager + @Mock private lateinit var subscriptionManager: SubscriptionManager + @Mock private lateinit var telephonyManager: TelephonyManager + @Mock private lateinit var logger: ConnectivityPipelineLogger + @Mock private lateinit var demoModeController: DemoModeController + + private val globalSettings = FakeSettings() + private val fakeNetworkEventsFlow = MutableStateFlow<FakeNetworkEventModel?>(null) + + private val scope = CoroutineScope(IMMEDIATE) + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + // Never start in demo mode + whenever(demoModeController.isInDemoMode).thenReturn(false) + + mockDataSource = + mock<DemoModeMobileConnectionDataSource>().also { + whenever(it.mobileEvents).thenReturn(fakeNetworkEventsFlow) + } + + realRepo = + MobileConnectionsRepositoryImpl( + connectivityManager, + subscriptionManager, + telephonyManager, + logger, + fakeBroadcastDispatcher, + globalSettings, + context, + IMMEDIATE, + scope, + mock(), + ) + + demoRepo = + DemoMobileConnectionsRepository( + dataSource = mockDataSource, + scope = scope, + context = context, + ) + + underTest = + MobileRepositorySwitcher( + scope = scope, + realRepository = realRepo, + demoMobileConnectionsRepository = demoRepo, + demoModeController = demoModeController, + ) + } + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `active repo matches demo mode setting`() = + runBlocking(IMMEDIATE) { + whenever(demoModeController.isInDemoMode).thenReturn(false) + + var latest: MobileConnectionsRepository? = null + val job = underTest.activeRepo.onEach { latest = it }.launchIn(this) + + assertThat(latest).isEqualTo(realRepo) + + startDemoMode() + + assertThat(latest).isEqualTo(demoRepo) + + finishDemoMode() + + assertThat(latest).isEqualTo(realRepo) + + job.cancel() + } + + @Test + fun `subscription list updates when demo mode changes`() = + runBlocking(IMMEDIATE) { + whenever(demoModeController.isInDemoMode).thenReturn(false) + + whenever(subscriptionManager.completeActiveSubscriptionInfoList) + .thenReturn(listOf(SUB_1, SUB_2)) + + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + // The real subscriptions has 2 subs + whenever(subscriptionManager.completeActiveSubscriptionInfoList) + .thenReturn(listOf(SUB_1, SUB_2)) + getSubscriptionCallback().onSubscriptionsChanged() + + assertThat(latest).isEqualTo(listOf(SUB_1, SUB_2)) + + // Demo mode turns on, and we should see only the demo subscriptions + startDemoMode() + fakeNetworkEventsFlow.value = validMobileEvent(subId = 3) + + // Demo mobile connections repository makes arbitrarily-formed subscription info + // objects, so just validate the data we care about + assertThat(latest).hasSize(1) + assertThat(latest!![0].subscriptionId).isEqualTo(3) + + finishDemoMode() + + assertThat(latest).isEqualTo(listOf(SUB_1, SUB_2)) + + job.cancel() + } + + private fun startDemoMode() { + whenever(demoModeController.isInDemoMode).thenReturn(true) + getDemoModeCallback().onDemoModeStarted() + } + + private fun finishDemoMode() { + whenever(demoModeController.isInDemoMode).thenReturn(false) + getDemoModeCallback().onDemoModeFinished() + } + + private fun getSubscriptionCallback(): SubscriptionManager.OnSubscriptionsChangedListener { + val callbackCaptor = + kotlinArgumentCaptor<SubscriptionManager.OnSubscriptionsChangedListener>() + verify(subscriptionManager) + .addOnSubscriptionsChangedListener(any(), callbackCaptor.capture()) + return callbackCaptor.value + } + + private fun getDemoModeCallback(): DemoMode { + val captor = kotlinArgumentCaptor<DemoMode>() + verify(demoModeController).addCallback(captor.capture()) + return captor.value + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + + private const val SUB_1_ID = 1 + private val SUB_1 = + mock<SubscriptionInfo>().also { whenever(it.subscriptionId).thenReturn(SUB_1_ID) } + + private const val SUB_2_ID = 2 + private val SUB_2 = + mock<SubscriptionInfo>().also { whenever(it.subscriptionId).thenReturn(SUB_2_ID) } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt new file mode 100644 index 000000000000..bf5ecd895c40 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt @@ -0,0 +1,249 @@ +/* + * 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.repository.demo + +import android.telephony.Annotation +import android.telephony.TelephonyManager +import androidx.test.filters.SmallTest +import com.android.settingslib.SignalIcon +import com.android.settingslib.mobile.TelephonyIcons +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState +import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileSubscriptionModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +/** + * Parameterized test for all of the common values of [FakeNetworkEventModel]. This test simply + * verifies that passing the given model to [DemoMobileConnectionsRepository] results in the correct + * flows emitting from the given connection. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(Parameterized::class) +internal class DemoMobileConnectionParameterizedTest(private val testCase: TestCase) : + SysuiTestCase() { + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val fakeNetworkEventFlow = MutableStateFlow<FakeNetworkEventModel?>(null) + + private lateinit var connectionsRepo: DemoMobileConnectionsRepository + private lateinit var underTest: DemoMobileConnectionRepository + private lateinit var mockDataSource: DemoModeMobileConnectionDataSource + + @Before + fun setUp() { + // The data source only provides one API, so we can mock it with a flow here for convenience + mockDataSource = + mock<DemoModeMobileConnectionDataSource>().also { + whenever(it.mobileEvents).thenReturn(fakeNetworkEventFlow) + } + + connectionsRepo = + DemoMobileConnectionsRepository( + dataSource = mockDataSource, + scope = testScope.backgroundScope, + context = context, + ) + + connectionsRepo.startProcessingCommands() + } + + @After + fun tearDown() { + testScope.cancel() + } + + @Test + fun demoNetworkData() = + testScope.runTest { + val networkModel = + FakeNetworkEventModel.Mobile( + level = testCase.level, + dataType = testCase.dataType, + subId = testCase.subId, + carrierId = testCase.carrierId, + inflateStrength = testCase.inflateStrength, + activity = testCase.activity, + carrierNetworkChange = testCase.carrierNetworkChange, + ) + + fakeNetworkEventFlow.value = networkModel + underTest = connectionsRepo.getRepoForSubId(subId) + + assertConnection(underTest, networkModel) + } + + private fun assertConnection( + conn: DemoMobileConnectionRepository, + model: FakeNetworkEventModel + ) { + when (model) { + is FakeNetworkEventModel.Mobile -> { + val subscriptionModel: MobileSubscriptionModel = conn.subscriptionModelFlow.value + assertThat(conn.subId).isEqualTo(model.subId) + assertThat(subscriptionModel.cdmaLevel).isEqualTo(model.level) + assertThat(subscriptionModel.primaryLevel).isEqualTo(model.level) + assertThat(subscriptionModel.dataActivityDirection).isEqualTo(model.activity) + assertThat(subscriptionModel.carrierNetworkChangeActive) + .isEqualTo(model.carrierNetworkChange) + + // TODO(b/261029387): check these once we start handling them + assertThat(subscriptionModel.isEmergencyOnly).isFalse() + assertThat(subscriptionModel.isGsm).isFalse() + assertThat(subscriptionModel.dataConnectionState) + .isEqualTo(DataConnectionState.Connected) + } + // MobileDisabled isn't combinatorial in nature, and is tested in + // DemoMobileConnectionsRepositoryTest.kt + else -> {} + } + } + + /** Matches [FakeNetworkEventModel] */ + internal data class TestCase( + val level: Int, + val dataType: SignalIcon.MobileIconGroup, + val subId: Int, + val carrierId: Int, + val inflateStrength: Boolean, + @Annotation.DataActivityType val activity: Int, + val carrierNetworkChange: Boolean, + ) { + override fun toString(): String { + return "INPUT(level=$level, " + + "dataType=${dataType.name}, " + + "subId=$subId, " + + "carrierId=$carrierId, " + + "inflateStrength=$inflateStrength, " + + "activity=$activity, " + + "carrierNetworkChange=$carrierNetworkChange)" + } + + // Convenience for iterating test data and creating new cases + fun modifiedBy( + level: Int? = null, + dataType: SignalIcon.MobileIconGroup? = null, + subId: Int? = null, + carrierId: Int? = null, + inflateStrength: Boolean? = null, + @Annotation.DataActivityType activity: Int? = null, + carrierNetworkChange: Boolean? = null, + ): TestCase = + TestCase( + level = level ?: this.level, + dataType = dataType ?: this.dataType, + subId = subId ?: this.subId, + carrierId = carrierId ?: this.carrierId, + inflateStrength = inflateStrength ?: this.inflateStrength, + activity = activity ?: this.activity, + carrierNetworkChange = carrierNetworkChange ?: this.carrierNetworkChange + ) + } + + companion object { + private val subId = 1 + + private val booleanList = listOf(true, false) + private val levels = listOf(0, 1, 2, 3) + private val dataTypes = + listOf( + TelephonyIcons.THREE_G, + TelephonyIcons.LTE, + TelephonyIcons.FOUR_G, + TelephonyIcons.NR_5G, + TelephonyIcons.NR_5G_PLUS, + ) + private val carrierIds = listOf(1, 10, 100) + private val inflateStrength = booleanList + private val activity = + listOf( + TelephonyManager.DATA_ACTIVITY_NONE, + TelephonyManager.DATA_ACTIVITY_IN, + TelephonyManager.DATA_ACTIVITY_OUT, + TelephonyManager.DATA_ACTIVITY_INOUT + ) + private val carrierNetworkChange = booleanList + + @Parameters(name = "{0}") @JvmStatic fun data() = testData() + + /** + * Generate some test data. For the sake of convenience, we'll parameterize only non-null + * network event data. So given the lists of test data: + * ``` + * list1 = [1, 2, 3] + * list2 = [false, true] + * list3 = [a, b, c] + * ``` + * We'll generate test cases for: + * + * Test (1, false, a) Test (2, false, a) Test (3, false, a) Test (1, true, a) Test (1, + * false, b) Test (1, false, c) + * + * NOTE: this is not a combinatorial product of all of the possible sets of parameters. + * Since this test is built to exercise demo mode, the general approach is to define a + * fully-formed "base case", and from there to make sure to use every valid parameter once, + * by defining the rest of the test cases against the base case. Specific use-cases can be + * added to the non-parameterized test, or manually below the generated test cases. + */ + private fun testData(): List<TestCase> { + val testSet = mutableSetOf<TestCase>() + + val baseCase = + TestCase( + levels.first(), + dataTypes.first(), + subId, + carrierIds.first(), + inflateStrength.first(), + activity.first(), + carrierNetworkChange.first() + ) + + val tail = + sequenceOf( + levels.map { baseCase.modifiedBy(level = it) }, + dataTypes.map { baseCase.modifiedBy(dataType = it) }, + carrierIds.map { baseCase.modifiedBy(carrierId = it) }, + inflateStrength.map { baseCase.modifiedBy(inflateStrength = it) }, + activity.map { baseCase.modifiedBy(activity = it) }, + carrierNetworkChange.map { baseCase.modifiedBy(carrierNetworkChange = it) }, + ) + .flatten() + + testSet.add(baseCase) + tail.toCollection(testSet) + + return testSet.toList() + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt new file mode 100644 index 000000000000..a8f6993373d2 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt @@ -0,0 +1,306 @@ +/* + * 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.repository.demo + +import android.telephony.SubscriptionInfo +import android.telephony.TelephonyManager.DATA_ACTIVITY_INOUT +import android.telephony.TelephonyManager.UNKNOWN_CARRIER_ID +import androidx.test.filters.SmallTest +import com.android.settingslib.SignalIcon +import com.android.settingslib.mobile.TelephonyIcons.THREE_G +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState +import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileSubscriptionModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel +import com.android.systemui.statusbar.pipeline.mobile.data.repository.demo.model.FakeNetworkEventModel.MobileDisabled +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import junit.framework.Assert +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +class DemoMobileConnectionsRepositoryTest : SysuiTestCase() { + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val fakeNetworkEventFlow = MutableStateFlow<FakeNetworkEventModel?>(null) + + private lateinit var underTest: DemoMobileConnectionsRepository + private lateinit var mockDataSource: DemoModeMobileConnectionDataSource + + @Before + fun setUp() { + // The data source only provides one API, so we can mock it with a flow here for convenience + mockDataSource = + mock<DemoModeMobileConnectionDataSource>().also { + whenever(it.mobileEvents).thenReturn(fakeNetworkEventFlow) + } + + underTest = + DemoMobileConnectionsRepository( + dataSource = mockDataSource, + scope = testScope.backgroundScope, + context = context, + ) + + underTest.startProcessingCommands() + } + + @Test + fun `network event - create new subscription`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + assertThat(latest).isEmpty() + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1) + + assertThat(latest).hasSize(1) + assertThat(latest!![0].subscriptionId).isEqualTo(1) + + job.cancel() + } + + @Test + fun `network event - reuses subscription when same Id`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + assertThat(latest).isEmpty() + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1, level = 1) + + assertThat(latest).hasSize(1) + assertThat(latest!![0].subscriptionId).isEqualTo(1) + + // Second network event comes in with the same subId, does not create a new subscription + fakeNetworkEventFlow.value = validMobileEvent(subId = 1, level = 2) + + assertThat(latest).hasSize(1) + assertThat(latest!![0].subscriptionId).isEqualTo(1) + + job.cancel() + } + + @Test + fun `multiple subscriptions`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1) + fakeNetworkEventFlow.value = validMobileEvent(subId = 2) + + assertThat(latest).hasSize(2) + + job.cancel() + } + + @Test + fun `mobile disabled event - disables connection - subId specified - single conn`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1, level = 1) + + fakeNetworkEventFlow.value = MobileDisabled(subId = 1) + + assertThat(latest).hasSize(0) + + job.cancel() + } + + @Test + fun `mobile disabled event - disables connection - subId not specified - single conn`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1, level = 1) + + fakeNetworkEventFlow.value = MobileDisabled(subId = null) + + assertThat(latest).hasSize(0) + + job.cancel() + } + + @Test + fun `mobile disabled event - disables connection - subId specified - multiple conn`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1, level = 1) + fakeNetworkEventFlow.value = validMobileEvent(subId = 2, level = 1) + + fakeNetworkEventFlow.value = MobileDisabled(subId = 2) + + assertThat(latest).hasSize(1) + + job.cancel() + } + + @Test + fun `mobile disabled event - subId not specified - multiple conn - ignores command`() = + testScope.runTest { + var latest: List<SubscriptionInfo>? = null + val job = underTest.subscriptionsFlow.onEach { latest = it }.launchIn(this) + + fakeNetworkEventFlow.value = validMobileEvent(subId = 1, level = 1) + fakeNetworkEventFlow.value = validMobileEvent(subId = 2, level = 1) + + fakeNetworkEventFlow.value = MobileDisabled(subId = null) + + assertThat(latest).hasSize(2) + + job.cancel() + } + + @Test + fun `demo connection - single subscription`() = + testScope.runTest { + var currentEvent: FakeNetworkEventModel = validMobileEvent(subId = 1) + var connections: List<DemoMobileConnectionRepository>? = null + val job = + underTest.subscriptionsFlow + .onEach { infos -> + connections = + infos.map { info -> underTest.getRepoForSubId(info.subscriptionId) } + } + .launchIn(this) + + fakeNetworkEventFlow.value = currentEvent + + assertThat(connections).hasSize(1) + val connection1 = connections!![0] + + assertConnection(connection1, currentEvent) + + // Exercise the whole api + + currentEvent = validMobileEvent(subId = 1, level = 2) + fakeNetworkEventFlow.value = currentEvent + assertConnection(connection1, currentEvent) + + job.cancel() + } + + @Test + fun `demo connection - two connections - update second - no affect on first`() = + testScope.runTest { + var currentEvent1 = validMobileEvent(subId = 1) + var connection1: DemoMobileConnectionRepository? = null + var currentEvent2 = validMobileEvent(subId = 2) + var connection2: DemoMobileConnectionRepository? = null + var connections: List<DemoMobileConnectionRepository>? = null + val job = + underTest.subscriptionsFlow + .onEach { infos -> + connections = + infos.map { info -> underTest.getRepoForSubId(info.subscriptionId) } + } + .launchIn(this) + + fakeNetworkEventFlow.value = currentEvent1 + fakeNetworkEventFlow.value = currentEvent2 + assertThat(connections).hasSize(2) + connections!!.forEach { + if (it.subId == 1) { + connection1 = it + } else if (it.subId == 2) { + connection2 = it + } else { + Assert.fail("Unexpected subscription") + } + } + + assertConnection(connection1!!, currentEvent1) + assertConnection(connection2!!, currentEvent2) + + // WHEN the event changes for connection 2, it updates, and connection 1 stays the same + currentEvent2 = validMobileEvent(subId = 2, activity = DATA_ACTIVITY_INOUT) + fakeNetworkEventFlow.value = currentEvent2 + assertConnection(connection1!!, currentEvent1) + assertConnection(connection2!!, currentEvent2) + + // and vice versa + currentEvent1 = validMobileEvent(subId = 1, inflateStrength = true) + fakeNetworkEventFlow.value = currentEvent1 + assertConnection(connection1!!, currentEvent1) + assertConnection(connection2!!, currentEvent2) + + job.cancel() + } + + private fun assertConnection( + conn: DemoMobileConnectionRepository, + model: FakeNetworkEventModel + ) { + when (model) { + is FakeNetworkEventModel.Mobile -> { + val subscriptionModel: MobileSubscriptionModel = conn.subscriptionModelFlow.value + assertThat(conn.subId).isEqualTo(model.subId) + assertThat(subscriptionModel.cdmaLevel).isEqualTo(model.level) + assertThat(subscriptionModel.primaryLevel).isEqualTo(model.level) + assertThat(subscriptionModel.dataActivityDirection).isEqualTo(model.activity) + assertThat(subscriptionModel.carrierNetworkChangeActive) + .isEqualTo(model.carrierNetworkChange) + + // TODO(b/261029387) check these once we start handling them + assertThat(subscriptionModel.isEmergencyOnly).isFalse() + assertThat(subscriptionModel.isGsm).isFalse() + assertThat(subscriptionModel.dataConnectionState) + .isEqualTo(DataConnectionState.Connected) + } + else -> {} + } + } +} + +/** Convenience to create a valid fake network event with minimal params */ +fun validMobileEvent( + level: Int? = 1, + dataType: SignalIcon.MobileIconGroup? = THREE_G, + subId: Int? = 1, + carrierId: Int? = UNKNOWN_CARRIER_ID, + inflateStrength: Boolean? = false, + activity: Int? = null, + carrierNetworkChange: Boolean = false, +): FakeNetworkEventModel = + FakeNetworkEventModel.Mobile( + level = level, + dataType = dataType, + subId = subId, + carrierId = carrierId, + inflateStrength = inflateStrength, + activity = activity, + carrierNetworkChange = carrierNetworkChange, + ) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt index aa7ab0df2ea3..c8df5ac17dff 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepositoryTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.systemui.statusbar.pipeline.mobile.data.repository +package com.android.systemui.statusbar.pipeline.mobile.data.repository.prod import android.os.UserHandle import android.provider.Settings @@ -40,6 +40,7 @@ import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionS import com.android.systemui.statusbar.pipeline.mobile.data.model.DefaultNetworkType import com.android.systemui.statusbar.pipeline.mobile.data.model.MobileSubscriptionModel import com.android.systemui.statusbar.pipeline.mobile.data.model.OverrideNetworkType +import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionsRepository import com.android.systemui.statusbar.pipeline.shared.ConnectivityPipelineLogger import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.argumentCaptor diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt index a953a3d802e6..359ea18fcb84 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionsRepositoryTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.systemui.statusbar.pipeline.mobile.data.repository +package com.android.systemui.statusbar.pipeline.mobile.data.repository.prod import android.content.Intent import android.net.ConnectivityManager |