diff --git a/src/com/android/settings/network/InternetPreferenceRepository.kt b/src/com/android/settings/network/InternetPreferenceRepository.kt index 30a98d7cb1d..6aa8db2c308 100644 --- a/src/com/android/settings/network/InternetPreferenceRepository.kt +++ b/src/com/android/settings/network/InternetPreferenceRepository.kt @@ -22,6 +22,7 @@ import android.net.wifi.WifiManager import android.provider.Settings import android.util.Log import com.android.settings.R +import com.android.settings.network.telephony.DataSubscriptionRepository import com.android.settings.wifi.WifiSummaryRepository import com.android.settings.wifi.repository.WifiRepository import com.android.settingslib.spaprivileged.settingsprovider.settingsGlobalBooleanFlow @@ -39,42 +40,50 @@ class InternetPreferenceRepository( private val context: Context, private val connectivityRepository: ConnectivityRepository = ConnectivityRepository(context), private val wifiSummaryRepository: WifiSummaryRepository = WifiSummaryRepository(context), + private val dataSubscriptionRepository: DataSubscriptionRepository = + DataSubscriptionRepository(context), private val wifiRepository: WifiRepository = WifiRepository(context), private val airplaneModeOnFlow: Flow = context.settingsGlobalBooleanFlow(Settings.Global.AIRPLANE_MODE_ON), ) { - fun summaryFlow(): Flow = connectivityRepository.networkCapabilitiesFlow() - .flatMapLatest { capabilities -> capabilities.summaryFlow() } - .onEach { Log.d(TAG, "summaryFlow: $it") } - .conflate() - .flowOn(Dispatchers.Default) + fun summaryFlow(): Flow = + connectivityRepository + .networkCapabilitiesFlow() + .flatMapLatest { capabilities -> capabilities.summaryFlow() } + .onEach { Log.d(TAG, "summaryFlow: $it") } + .conflate() + .flowOn(Dispatchers.Default) private fun NetworkCapabilities.summaryFlow(): Flow { - if (hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + if ( + hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) ) { for (transportType in transportTypes) { - if (transportType == NetworkCapabilities.TRANSPORT_WIFI) { - return wifiSummaryRepository.summaryFlow() + when (transportType) { + NetworkCapabilities.TRANSPORT_WIFI -> return wifiSummaryRepository.summaryFlow() + NetworkCapabilities.TRANSPORT_CELLULAR -> + return dataSubscriptionRepository.dataSummaryFlow() } } } return defaultSummaryFlow() } - private fun defaultSummaryFlow(): Flow = combine( - airplaneModeOnFlow, - wifiRepository.wifiStateFlow(), - ) { airplaneModeOn: Boolean, wifiState: Int -> - context.getString( - if (airplaneModeOn && wifiState != WifiManager.WIFI_STATE_ENABLED) { - R.string.condition_airplane_title - } else { - R.string.networks_available - } - ) - } + private fun defaultSummaryFlow(): Flow = + combine( + airplaneModeOnFlow, + wifiRepository.wifiStateFlow(), + ) { airplaneModeOn: Boolean, wifiState: Int -> + context.getString( + if (airplaneModeOn && wifiState != WifiManager.WIFI_STATE_ENABLED) { + R.string.condition_airplane_title + } else { + R.string.networks_available + } + ) + } private companion object { private const val TAG = "InternetPreferenceRepo" diff --git a/src/com/android/settings/network/SubscriptionUtil.java b/src/com/android/settings/network/SubscriptionUtil.java index 7e3f78dde0e..74a10e9780a 100644 --- a/src/com/android/settings/network/SubscriptionUtil.java +++ b/src/com/android/settings/network/SubscriptionUtil.java @@ -408,7 +408,6 @@ public class SubscriptionUtil { * * @return map of active subscription ids to display names. */ - @VisibleForTesting public static CharSequence getUniqueSubscriptionDisplayName( Integer subscriptionId, Context context) { final Map displayNames = getUniqueSubscriptionDisplayNames(context); diff --git a/src/com/android/settings/network/telephony/DataSubscriptionRepository.kt b/src/com/android/settings/network/telephony/DataSubscriptionRepository.kt new file mode 100644 index 00000000000..99f639b77c9 --- /dev/null +++ b/src/com/android/settings/network/telephony/DataSubscriptionRepository.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.network.telephony + +import android.content.Context +import android.content.IntentFilter +import android.telephony.SubscriptionManager +import android.telephony.TelephonyCallback +import android.telephony.TelephonyManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.settings.R +import com.android.settings.network.SubscriptionUtil +import com.android.settingslib.spaprivileged.framework.common.broadcastReceiverFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +class DataSubscriptionRepository( + private val context: Context, + private val getDisplayName: (subId: Int) -> String = { subId -> + SubscriptionUtil.getUniqueSubscriptionDisplayName(subId, context).toString() + }, +) { + private val telephonyManager = context.getSystemService(TelephonyManager::class.java)!! + private val subscriptionManager = context.requireSubscriptionManager() + + fun defaultDataSubscriptionIdFlow(): Flow = + context + .broadcastReceiverFlow( + IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED) + ) + .map { it.getIntExtra(SUBSCRIPTION_KEY, SubscriptionManager.INVALID_SUBSCRIPTION_ID) } + .onStart { emit(SubscriptionManager.getDefaultDataSubscriptionId()) } + .conflate() + .flowOn(Dispatchers.Default) + + fun activeDataSubscriptionIdFlow(): Flow = + telephonyManager.telephonyCallbackFlow { + object : TelephonyCallback(), TelephonyCallback.ActiveDataSubscriptionIdListener { + override fun onActiveDataSubscriptionIdChanged(subId: Int) { + trySend(subId) + Log.d(TAG, "activeDataSubscriptionIdFlow: $subId") + } + } + } + + fun dataSummaryFlow(): Flow = + combine(defaultDataSubscriptionIdFlow(), activeDataSubscriptionIdFlow()) { + defaultSubId, + activeSubId -> + DataSubscriptionIds(defaultSubId, activeSubId) + } + .distinctUntilChanged() + .map { it.getDataSummary() } + .conflate() + .flowOn(Dispatchers.Default) + + private data class DataSubscriptionIds( + val defaultSubId: Int, + val activeSubId: Int, + ) + + private fun DataSubscriptionIds.getDataSummary(): String { + val activeSubInfo = subscriptionManager.getActiveSubscriptionInfo(activeSubId) ?: return "" + if (!SubscriptionUtil.isSubscriptionVisible(subscriptionManager, context, activeSubInfo)) { + return getDisplayName(defaultSubId) + } + val uniqueName = getDisplayName(activeSubId) + return if (activeSubId == defaultSubId) { + uniqueName + } else { + context.getString(R.string.mobile_data_temp_using, uniqueName) + } + } + + companion object { + private const val TAG = "DataSubscriptionRepo" + + @VisibleForTesting const val SUBSCRIPTION_KEY = "subscription" + } +} diff --git a/src/com/android/settings/network/telephony/TelephonyRepository.kt b/src/com/android/settings/network/telephony/TelephonyRepository.kt index d0d53b7be95..7c334ee44db 100644 --- a/src/com/android/settings/network/telephony/TelephonyRepository.kt +++ b/src/com/android/settings/network/telephony/TelephonyRepository.kt @@ -114,14 +114,17 @@ class TelephonyRepository( fun Context.telephonyCallbackFlow( subId: Int, block: ProducerScope.() -> TelephonyCallback, -): Flow = callbackFlow { - val telephonyManager = telephonyManager(subId) +): Flow = telephonyManager(subId).telephonyCallbackFlow(block) +/** Creates an instance of a cold Flow for Telephony callback. */ +fun TelephonyManager.telephonyCallbackFlow( + block: ProducerScope.() -> TelephonyCallback, +): Flow = callbackFlow { val callback = block() - telephonyManager.registerTelephonyCallback(Dispatchers.Default.asExecutor(), callback) + registerTelephonyCallback(Dispatchers.Default.asExecutor(), callback) - awaitClose { telephonyManager.unregisterTelephonyCallback(callback) } + awaitClose { unregisterTelephonyCallback(callback) } }.conflate().flowOn(Dispatchers.Default) fun Context.telephonyManager(subId: Int): TelephonyManager = diff --git a/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt b/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt index 68869d8e903..90e9254cb0f 100644 --- a/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt +++ b/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt @@ -46,6 +46,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.android.settings.R import com.android.settings.network.SubscriptionInfoListViewModel +import com.android.settings.network.telephony.DataSubscriptionRepository import com.android.settings.network.telephony.TelephonyRepository import com.android.settings.spa.network.PrimarySimRepository.PrimarySimInfo import com.android.settings.wifi.WifiPickerTrackerHelper @@ -158,7 +159,7 @@ open class NetworkCellularGroupProvider : SettingsPageProvider { selectableSubscriptionInfoListFlow, context.defaultVoiceSubscriptionFlow(), context.defaultSmsSubscriptionFlow(), - context.defaultDefaultDataSubscriptionFlow(), + DataSubscriptionRepository(context).defaultDataSubscriptionIdFlow(), this::refreshUiStates, ).flowOn(Dispatchers.Default) @@ -370,15 +371,6 @@ private fun Context.defaultSmsSubscriptionFlow(): Flow = ).map { SubscriptionManager.getDefaultSmsSubscriptionId() } .conflate().flowOn(Dispatchers.Default) -private fun Context.defaultDefaultDataSubscriptionFlow(): Flow = - merge( - flowOf(null), // kick an initial value - broadcastReceiverFlow( - IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED) - ), - ).map { SubscriptionManager.getDefaultDataSubscriptionId() } - .conflate().flowOn(Dispatchers.Default) - suspend fun setDefaultVoice( subscriptionManager: SubscriptionManager?, subId: Int diff --git a/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt index 4cd65e74505..aafd77f1a4a 100644 --- a/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt +++ b/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt @@ -22,6 +22,7 @@ import android.net.wifi.WifiManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settings.R +import com.android.settings.network.telephony.DataSubscriptionRepository import com.android.settings.wifi.WifiSummaryRepository import com.android.settings.wifi.repository.WifiRepository import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull @@ -42,30 +43,50 @@ class InternetPreferenceRepositoryTest { private val mockConnectivityRepository = mock() private val mockWifiSummaryRepository = mock() + private val mockDataSubscriptionRepository = mock() private val mockWifiRepository = mock() private val airplaneModeOnFlow = MutableStateFlow(false) - private val repository = InternetPreferenceRepository( - context = context, - connectivityRepository = mockConnectivityRepository, - wifiSummaryRepository = mockWifiSummaryRepository, - wifiRepository = mockWifiRepository, - airplaneModeOnFlow = airplaneModeOnFlow, - ) + private val repository = + InternetPreferenceRepository( + context = context, + connectivityRepository = mockConnectivityRepository, + wifiSummaryRepository = mockWifiSummaryRepository, + dataSubscriptionRepository = mockDataSubscriptionRepository, + wifiRepository = mockWifiRepository, + airplaneModeOnFlow = airplaneModeOnFlow, + ) @Test fun summaryFlow_wifi() = runBlocking { - val wifiNetworkCapabilities = NetworkCapabilities.Builder().apply { - addTransportType(NetworkCapabilities.TRANSPORT_WIFI) - addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - }.build() + val wifiNetworkCapabilities = + NetworkCapabilities.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() mockConnectivityRepository.stub { on { networkCapabilitiesFlow() } doReturn flowOf(wifiNetworkCapabilities) } - mockWifiSummaryRepository.stub { - on { summaryFlow() } doReturn flowOf(SUMMARY) + mockWifiSummaryRepository.stub { on { summaryFlow() } doReturn flowOf(SUMMARY) } + + val summary = repository.summaryFlow().firstWithTimeoutOrNull() + + assertThat(summary).isEqualTo(SUMMARY) + } + + @Test + fun summaryFlow_cellular() = runBlocking { + val wifiNetworkCapabilities = + NetworkCapabilities.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + mockConnectivityRepository.stub { + on { networkCapabilitiesFlow() } doReturn flowOf(wifiNetworkCapabilities) } + mockDataSubscriptionRepository.stub { on { dataSummaryFlow() } doReturn flowOf(SUMMARY) } val summary = repository.summaryFlow().firstWithTimeoutOrNull() diff --git a/tests/spa_unit/src/com/android/settings/network/telephony/DataSubscriptionRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/network/telephony/DataSubscriptionRepositoryTest.kt new file mode 100644 index 00000000000..5b8d020bc76 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/network/telephony/DataSubscriptionRepositoryTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.network.telephony + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.TelephonyCallback +import android.telephony.TelephonyManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.network.telephony.DataSubscriptionRepository.Companion.SUBSCRIPTION_KEY +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.android.settingslib.spa.testutils.toListWithTimeout +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class DataSubscriptionRepositoryTest { + + private var activeDataSubIdListener: TelephonyCallback.ActiveDataSubscriptionIdListener? = null + + private val mockTelephonyManager = + mock { + on { registerTelephonyCallback(any(), any()) } doAnswer + { + activeDataSubIdListener = + it.arguments[1] as TelephonyCallback.ActiveDataSubscriptionIdListener + } + } + + private val mockSubscriptionManager = + mock { + on { getActiveSubscriptionInfo(SUB_ID_10) } doReturn + SubscriptionInfo.Builder().setId(SUB_ID_10).build() + on { getActiveSubscriptionInfo(SUB_ID_20) } doReturn + SubscriptionInfo.Builder().setId(SUB_ID_20).build() + } + + private val context: Context = + spy(ApplicationProvider.getApplicationContext()) { + on { getSystemService(TelephonyManager::class.java) } doReturn mockTelephonyManager + on { getSystemService(SubscriptionManager::class.java) } doReturn + mockSubscriptionManager + + doAnswer { + val broadcastReceiver = it.arguments[0] as BroadcastReceiver + val intent = Intent().apply { putExtra(SUBSCRIPTION_KEY, SUB_ID_10) } + broadcastReceiver.onReceive(mock, intent) + null + } + .whenever(mock) + .registerReceiver( + any(), + argThat { + hasAction(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED) + }, + any(), + ) + } + + private val repository = DataSubscriptionRepository(context) { subId -> "Name$subId" } + + @Test + fun defaultDataSubscriptionIdFlow() = runBlocking { + val defaultSubIdDeferred = async { + repository.defaultDataSubscriptionIdFlow().toListWithTimeout() + } + delay(100) + + assertThat(defaultSubIdDeferred.await()).contains(SUB_ID_10) + } + + @Test + fun activeDataSubscriptionIdFlow() = runBlocking { + val activeSubIdDeferred = async { + repository.activeDataSubscriptionIdFlow().toListWithTimeout() + } + delay(100) + + activeDataSubIdListener?.onActiveDataSubscriptionIdChanged(SUB_ID_20) + + assertThat(activeSubIdDeferred.await()).contains(SUB_ID_20) + } + + @Test + fun dataSummaryFlow_defaultIsActive() = runBlocking { + val summaryDeferred = async { repository.dataSummaryFlow().firstWithTimeoutOrNull() } + delay(100) + + activeDataSubIdListener?.onActiveDataSubscriptionIdChanged(SUB_ID_10) + + assertThat(summaryDeferred.await()).isEqualTo("Name10") + } + + @Test + fun dataSummaryFlow_defaultIsNotActive() = runBlocking { + val summaryDeferred = async { repository.dataSummaryFlow().firstWithTimeoutOrNull() } + delay(100) + + activeDataSubIdListener?.onActiveDataSubscriptionIdChanged(SUB_ID_20) + + assertThat(summaryDeferred.await()).isEqualTo("Temporarily using Name20") + } + + private companion object { + const val SUB_ID_10 = 10 + const val SUB_ID_20 = 20 + } +}