Almost exactly one year ago, I published a post on how to monitor changes in connectivity status. Since then, 5G networks have started to become more common. Also, I have found some improvements in how to handle this monitoring. Hence this follow-up post.
5G networks offer extremely high data transfer rates, which we, as app developers, can leverage to provider a richer user experience. For example, such data rates facilitate over-the-air augmented reality and other such things. However, we should only offer such services when the bandwidth is available to support them. It is also worth considering if the network is metered so that we don’t use all the user’s data.
Detecting 5G networks
To detect a connection to a 5G network, we can register for callbacks from TelephonyManager
. This callback will happen whenever the network characteristics change. We register for callback using the listen()
method:
telephonyManager.listen(callback, phoneStateListener.LISTEN_DISPLAY_INFO_CHANGED)
We can unregister by calling the same method with PhoneStateListener.LISTEN_NONE
as the second argument.
The full class for this is as follows:
class FiveGConnectionProvider @Inject constructor(flow: Flow) { private val fiveGTypes = listOf( TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA_MMWAVE ) companion object { fun getFlow(telephonyManager: TelephonyManager) = callbackFlow { println("Starting 5G Status flow") val callback = object : PhoneStateListener() { override fun onDisplayInfoChanged(displayInfo: TelephonyDisplayInfo) { try { super.onDisplayInfoChanged(displayInfo) println("Emitting 5G Status: ${displayInfo.networkType}") offer(displayInfo.networkType) } catch (e: SecurityException) { println("Emitting 5G Status: ") offer(null) } } } try { offer(telephonyManager.dataNetworkType) } catch (e: SecurityException) { println("Emitting 5G Status: ") offer(null) } telephonyManager.listen(callback, PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED) awaitClose { telephonyManager.listen(callback, PhoneStateListener.LISTEN_NONE) } } } val fiveGFlow = flow.map { it?.let { fiveGTypes.contains(it) } } }
All of the logic for interacting with the TelephonyManager
is done inside the getFlow()
method. This may appear to be slightly odd – doing this in a companion object function. However, it should make more sense as we proceed.
The getFlow()
method uses a callbackFlow
to wrap the callback behaviour. I have written about callbackFlow
previously.
The return value of getFlow()
is a Flow<Int?>
which will emit a value each time the callback is invoked. If we lack the necessary permissions, then null
will be emitted. Otherwise it will emit an Int
representing the network type.
The Hilt module uses this to create an instance of Flow<Int?>
:
@Module @InstallIn(SingletonComponent::class) class FiveGModule { @Provides fun providesTelephonyManager(@ApplicationContext context: Context): TelephonyManager = context.getSystemService(TelephonyManager::class.java) @Provides fun providesTelephonyManagerFlow(telephonyManager: TelephonyManager) = FiveGConnectionProvider.getFlow(telephonyManager) @Provides @Named("FiveG") fun providesFiveGFlow(provider: FiveGConnectionProvider) = provider.fiveGFlow . . . }
Hilt can now provide an instance FiveGConnectionProvider
because its @Inject
constructor dependency of a Flow<Int?>
can be satisfied.
The fiveGFlow
val performs the logic for determining whether the current network is a 5G one.
While this may feel a little awkward, it makes this logic much easier to test. We do not own TelephonyManager
nor do we own callbackFlow
so we don’t need to test them. By keeping getFlow()
separate from our main logic, we can test the logic in isolation. It we have any calls to TelephonyManager
in our logic, it will make testing much harder. The injected constructor makes for a simple and clean test:
class FiveGConnectionProviderTest { @Test fun testFiveGNetworkFlow() = runBlockingTest { val flow = flowOf( OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO, OVERRIDE_NETWORK_TYPE_NR_NSA, OVERRIDE_NETWORK_TYPE_NR_NSA_MMWAVE, null, OVERRIDE_NETWORK_TYPE_NONE ) val fiveGConnectionProvider = FiveGConnectionProvider(flow) val result = fiveGConnectionProvider.fiveGFlow.toList() assertThat(result, equalTo(listOf(true, true, true, null, false))) } }
We create a flow of states, then use that as the constructor argument to our FiveGConnectionProvider
instance. We can test the logic of fiveGFlow
by verifying the output states for each of the input states.
Detecting Metered Networks
We can use a similar technique for detecting metered networks. Albeit using ConnectivityManager
:
NetworkCapabilitiesProvider @Inject constructor( flow: Flow) { private val notMeteredTypes = listOf( NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED, NetworkCapabilities.NET_CAPABILITY_NOT_METERED ) companion object { fun flow(connectivityManager: ConnectivityManager) = callbackFlow { println("Starting Capabilities Status flow") val callback = object : ConnectivityManager.NetworkCallback() { override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { super.onCapabilitiesChanged(network, networkCapabilities) println("Emitting Capabilities Status: $networkCapabilities") offer(networkCapabilities) } } connectivityManager.registerDefaultNetworkCallback(callback) awaitClose { connectivityManager.unregisterNetworkCallback(callback) } } } val isMeteredFlow = flow.map { notMeteredTypes.none(it::hasCapability) } }
In this case getFlow()
returns a Flow<NetworkCapabilities>
flow, and isMeteredFlow
filters this to a Flow<Boolean
which indicates whether the current network is metered.
The Hilt and test code is very similar. The only real difference in the test code is that I’ve had to mock NetworkCapabilites
instances for the input flow states. This has a slight smell to it because we shouldn’t mock something that we don’t own. However, in this case we are not testing it, but merely using it to model input states.
The ViewModel
With these two flows we can now create our ViewModel
:
@HiltViewModel class FiveGViewModel @Inject constructor( @Named("FiveG") fiveGFlow: Flow, @Named("Metered") isMeteredFlow: Flow ) : ViewModel() { val statusFlow = fiveGFlow .combine(isMeteredFlow) { connection, isMetered -> println("Collecting 5G Status: $connection, $isMetered") if (connection == null) { Status.NoPermission } else { Status.FiveGStatus( is5G = connection, isMetered = isMetered ) } } }
We use the combine
operator to tie the two flows together. It emits a new state whenever either of them changes. There are different forms of this operator which can combine more than two states.
It is important to note that the ViewModel
is completely agnostic of how these states are determined. It has no knowledge of TelephonyManager
nor ConnectivityManager
which are both system components that we do not own. Therefore this ViewModel
is also easy to test:
class FiveGViewModelTest { lateinit var viewModel: FiveGViewModel @Test fun givenNullFiveGState_whenWeCollect_ThenStatusIsNoPermission() = runBlockingTest { val fiveGFlow = flow{ emit(null) } val netFlow = flow { emit(false) } viewModel = FiveGViewModel(fiveGFlow, netFlow) val results = viewModel.statusFlow.toList() assertThat(results, equalTo(listOf(Status.NoPermission))) } . . . }
There are more tests for the various state permutations but for brevity I have only included one here. The pattern is, once again, that we can provide fake flows to the FiveGViewModel
constructor. We can check the output state for given input states.
Consumption
A consumer of the statusFlow
from the ViewModel
can now control the app behaviour based upon two boolean values which indicate whether the current network is 5G or if it is metered. We can easily extend this model to include other factors, as well. Such as mobile data or WiF etc., etc.
In the sample app I provide a text indication of these two factors, but in a real app the behaviour will depend on the capabilities of the app itself.
Conclusion
Flows are a great enabler when it comes to handling state changes. If we inject flows via the constructor then we can mock these flows when it comes to testing.
The source code for this article is available here.
© 2021, Mark Allison. All rights reserved.
Copyright © 2021 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.