ConnectivityManager / Flow / NetworkCallback

5G Connectivity

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.