I am a firm believer that everything that we do as app developers should be focused on UX – what the user experiences when using our app. While things like adding analytics gathering may not seem to be UX-related, but insights in to how users are actually using our apps and understanding the churn areas can help us to improve the users’ journey through the app. One area that will always frustrate users is when they receive error messages from the app. A common case where this happens is if the user has poor or patchy data and backend requests fail. While there are many different approaches we can take to mitigate this situation (such as caching strategies), this post will focus on actively detecting connectivity issues so that we may adjust the UI to let the user know that an operation will fail rather than waiting for the user to make an action and then displaying an error message.
This is by no means a new problem that we’re looking at – it has been around for as long as mobile devices with data connections have existed. That said there have been some changes in Android over the years which have impacted how we need to approach this sometimes tricky issue, and we’ll look at it with the tools available to us in 2020.
One of the big changes occurred in Android Nougat (API 24+) which no longer permits an app to register for CONNECTIVITY_ACTION
in the manifest. This was part of project Svelte which was designed to improve battery life, and many apps were using this to monitor connectivity changes in the background which would cause these apps to get woken on every connectivity change event. We are now restricted to only receiving such notifications while our app is active which is fine for the vast majority of apps.
The heart of our monitoring is a LiveData
implementation:
class NetworkCapabilitiesLiveData @Inject constructor( private val context: Context, private val connectivityManager: ConnectivityManager ) : LiveData() { private var activeNetwork: Network? = null private val allNetworks = mutableMapOf () override fun onActive() { super.onActive() connectivityManager.registerNetworkCallback( NetworkRequest.Builder().build(), callback ) context.registerReceiver( receiver, IntentFilter(CONNECTIVITY_CHANGE) ) } override fun onInactive() { context.unregisterReceiver(receiver) connectivityManager.unregisterNetworkCallback(callback) super.onInactive() } private val callback = object : ConnectivityManager.NetworkCallback() { override fun onLost(network: Network) { allNetworks.remove(network) } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { allNetworks[network] = networkCapabilities updateActiveNetwork() } } private fun updateActiveNetwork() { activeNetwork = connectivityManager.activeNetwork val capabilities = allNetworks[activeNetwork] if (capabilities != value) { postValue(capabilities) } } private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == CONNECTIVITY_CHANGE) { updateActiveNetwork() } } } companion object { private const val CONNECTIVITY_CHANGE = "android.net.conn.CONNECTIVITY_CHANGE" } }
This registers for two distinct types of event: Firstly it receives all updates to the NetworkCapabilities
of all available networks, and secondly registers for connectivity change events – i.e. when the active network changes. The capabilities get stored in a map named allNetworks
, and the currently active network is stored in activeNetwork
. Whenever either of these is updated, the updateActiveNetwork
method is called and this retrieves the capabilities of the currently active network and emits them if they are different to the current value of the LiveData
. So this LiveData
will emit the capabilities of the currently active network whenever either the active network or its capabilities change. It will also emit null
if there is no active network because we are out of range of any possible networks.
Our ViewModel
is pretty simple:
class MainActivityViewModel @Inject constructor( networkCapabilitiesLiveData: NetworkCapabilitiesLiveData ) : ViewModel() { private val mapper = NetworkCapabilitiesMapper() val connectionState: LiveData= Transformations.map(networkCapabilitiesLiveData) { capabilities -> mapper.map(capabilities) } }
This consumes the LiveData
we just created and performs a transformation to a data model that can be easily consumed by our View. ConnectionState
is a sealed class:
sealed class ConnectionState { object NotConnected : ConnectionState() data class Connected( val signalStrength: SignalStrength, val linkDownstreamBandwidthKbps: Int, val linkUpstreamBandwidthKbps: Int, val transports: List, val hasInternet: Boolean ) : ConnectionState() sealed class SignalStrength(val strength: Int) { class DbSignalStrength(strength: Int) : SignalStrength(strength) object NoSignalStrength : SignalStrength(UNAVAILABLE) companion object { const val UNAVAILABLE = Int.MIN_VALUE fun get(capabilities: NetworkCapabilities): SignalStrength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && capabilities.signalStrength != UNAVAILABLE) { DbSignalStrength(capabilities.signalStrength) } else { NoSignalStrength } } } }
This contains two concrete states: Connected
and NotConnected
. Hopefully the naming makes it pretty clear what these states represent. The Connected
state contains data about the connection which will be extracted from the network capabilities emitted by NetworkCapabilitiesLiveData
. In most cases detailed information will not be required but some types of app may have specific requirements so I have included a few details here. We’ll explore this in a little more depth in a little while.
The SignalStrength
sealed class is included because signal strength data may not always be available. It is only available in Android Q and later, and even in Q it is not supported on all transports, so there is some logic built in to return either DbSignalStrength
to provide a signal strength value in decibels, or NoSignalStrength
to indicate that no signal strength value is available. It is not mentioned in the reference docs what represents “not available”. However in my testing I found that this was generally represented as the smallest possible value of an Int
– Int.MIN_VALUE
. This makes sense if we consider that signal strength values represent the signal attenuation which will always be a negative value. -100
is smaller than -10
(even though that seems counterintuitive at first glance) and Int.MIN_VALUE
is the smallest possible value that can be represented as an Int
which is -2147483648
.
However that “no signal strength available” behaviour is based on personal observation and may not be consistent across different transports, bearers, drivers, and OEMs (cough…Samsung…cough).
The mapping from NetworkCapabilities
to ConnectionState
is performed by NetworkCapabilitiesMapper
which MainActivityViewModel
uses to perform the transformation:
class NetworkCapabilitiesMapper { private val transports: Mapinit { val transportMap = mutableMapOf( TRANSPORT_BLUETOOTH to "Bluetooth", TRANSPORT_CELLULAR to "Cellular", TRANSPORT_ETHERNET to "Ethernet", TRANSPORT_VPN to "VPN", TRANSPORT_WIFI to "Wi-Fi" ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { transportMap += TRANSPORT_LOWPAN to "LoWPAN" } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { transportMap += TRANSPORT_WIFI_AWARE to "Wi-Fi Aware" } transports = transportMap } fun map(capabilities: NetworkCapabilities?): ConnectionState = capabilities?.let { ConnectionState.Connected( signalStrength = ConnectionState.SignalStrength.get(capabilities), linkDownstreamBandwidthKbps = capabilities.linkDownstreamBandwidthKbps, linkUpstreamBandwidthKbps = capabilities.linkUpstreamBandwidthKbps, transports = transportNames(capabilities), hasInternet = capabilities.hasCapability(NET_CAPABILITY_INTERNET) ) } ?: ConnectionState.NotConnected private fun transportNames(capabilities: NetworkCapabilities): List = transports.filter { entry -> capabilities.hasTransport(entry.key) }.map { it.value } }
The init()
function creates a map of the transport IDs to human readable names based on the OS version of the device the app is running on. Some transport types are only available from specific API levels onward. The transportNames()
method performs the mapping of the transport types in the capabilities to a list of transport names. Although it may seem strange that there can be more than one transport type, it is possible to have a VPN running over another transport, so there can be multiple transports. The map()
method performs the mapping itself and uses the mechanisms that we’ve already discussed to perform the individual field mappings.
One thing that is worth mentioning is the hasInternet
field – this is really important. Having an active network does not mean that it will allow data transfer. The use case to consider here is that the only available network is a public Wi-Fi which requires the user to login. Until the user logs in to this W-Fi network our app will not be able to access Internet resources. By checking for the NET_CAPABILITY_INTERNET
capability we are able to determine whether the active network will provide Internet access. There are many more capabilities that we can check here, but this one is pretty universal and the others mill have different relevance to different kinds of apps.
This is where it becomes difficult to give any hard and fast rules. The phrase “different kinds of apps” sums things up quite nicely as different apps will have different network requirements. What kinds of information that needs surfacing to the UI layer really depends on the nature of the app. For example, a text chat app can work quite well even over slow networks because the payloads of each network transaction are relatively small; But a video streaming app will have vastly different network requirements because of the much larger amounts of data involved. This is what was mentioned earlier about the details that need surfacing in order to provide a good user experience – it depends upon the nature of the app.
Examples are always good, so let’s consider the video conferencing app. This will consume relatively high amounts of data and the user experience will suffer if the data rate drops or even disappears altogether. While we cannot stop this from happening, we can give a visual indication that the quality is not as good as it could be. I’m sure that most developers have been on video calls and seen indicators that certain participants have a poor connection. We can get some coarse-grained indicators of network quality from the code we’ve already looked at – specifically the linkDownstreamBandwidthKbps
and linkUpstreamBandwidthKbps
fields in NetworkCapabilities
. These do not provide realtime data rate estimations, but physical maxima for a given transport. The key thing here is that they will change if the cellular network type were to drop from, for example, LTE (maximum 102.40 Mbps) down to EDGE (236 Kbps). Although we are not guaranteed that we’ll get the maximum LTE data rate, we can be sure that we will not exceed the maximum EDGE data rate, so we can give the user a visual warning.
In the example app, I merely expose this raw data – there are few use cases where this would be necessary except for apps directly aimed at techies. For that reason I haven’t included the rendering code in this article, but it’s available in the sample code for those that are interested.
© 2020, Mark Allison. All rights reserved.
Copyright © 2020 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.