Network type in Android: watching for 5G/4G/3G/2G capability
Apps can benefit from knowing what kind of network they’re connected to, for example to enable certain features that require the bandwidth and low latency that 5G provides. Likewise, expectation can be set for slower load times if only 2G or 3G are available.
Our friend here is the TelephonyManager
class which provides all sorts of information on cellular status. Including network type! But using it is a pretty complex operation, with many different cases for different Android versions.
Here I provide a sample app which detects the type of cellular network we’re connected to — not just 5G / 4G / 3G / 2G, but specific subtype too. It usesTelephonyManager
and is written using Jetpack Compose, view models and Kotlin flows.
Want to skip straight to the code? Get it here.
Using TelephonyManager to register to receive network info updates
Here’s how we get the TelephonyManager
instance:
val telephonyManager =
context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
…where context
is a Context
instance. Note that some phones have multiple SIMs; if you want to query a specific one, then call .createForSubscriptionId(simCardNumber)
on a TelephonyManager
instance.
With this instance, we can now get network info updates. The process used depends on Android version — that is, the user’s Android version, not your app’s target API level.
Android ≥ 12 (API ≥ 31)
Android 12 and upwards is the easiest case because there’s a dedicated listener and no permissions are required.
To register to receive network type information, we use registerTelephonyCallback(Executor, TelephonyCallback)
as follows:
// The thread Executor used to run the listener. This governs how threads are created and
// reused. Here we use a single thread.
val exec = Executors.newSingleThreadExecutor()
// Create the callback object
val callback = object : TelephonyCallback(), TelephonyCallback.DisplayInfoListener {
override fun onDisplayInfoChanged(telephonyDisplayInfo: TelephonyDisplayInfo) {
//TODO: This is next
}
}
// Finally, register the callback so it can start receiving results.
telephonyManager.registerTelephonyCallback(exec, callback)
To shut down our listener, we use:
telephonyManager.unregisterTelephonyCallback(callback)
Android 11 only (API 30)
The original way of registering a callback on the telephony manager was using thelisten
method. This accepts various types of listener; the one we want implements onDisplayInfoChanged
.
Which, hilariously, came and went within a single Android version:
This requires the READ_PHONE_STATE
permission. We’re going to deal with that later, in our UI code. For now we will continue as if we already have it.
// (At the top of the file)
@file:Suppress("DEPRECATION") //Suppressed as required to support old version
// SDK 30 uses TelephonyManager.listen() to listen for TelephonyDisplayInfo changes.
// It requires READ_PHONE_STATE permission.
@Suppress("OVERRIDE_DEPRECATION") //Suppressed as required to support old version
// This is the object that will receive the results
val callback = object : PhoneStateListener(exec) {
override fun onDisplayInfoChanged(telephonyDisplayInfo: TelephonyDisplayInfo) {
//TODO: This is next
}
}
// Start listening for results
telephonyManager.listen(callback, PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED)
To shut down this listener, we use:
telephonyManager.listen(callback, 0)
…which is a slightly odd syntax, since .listen()
is the method used to sign up rather than sign off. Hey ho.
Android ≥ 7 (API ≥ 24)
Android 10 and below does not have any method for listening to network type changes. To support older versions, you would need to implement a loop which actively checks every few seconds.
The code to check is:
val networkType = telephonyManager.dataNetworkType
This requires READ_PHONE_STATE permission.
Note that Android 10 and below cannot be 5G, since that was only made available on Android 11 and above.
The returned values: network type constants
The Android 11 and ≥12 code above receives a callback with a TelephonyDisplayInfo
object. This contains a networkType
and an overrideNetworkType
. The Android ≤10 code receives only a networkType
.
In either case, the networkType
can be one of the following:
val baseTypeString = when(networkType) {
TelephonyManager.NETWORK_TYPE_CDMA -> "CDMA"
TelephonyManager.NETWORK_TYPE_1xRTT -> "1xRTT"
TelephonyManager.NETWORK_TYPE_EDGE -> "EDGE"
TelephonyManager.NETWORK_TYPE_EHRPD -> "eHRPD"
TelephonyManager.NETWORK_TYPE_EVDO_0 -> "EVDO rev 0"
TelephonyManager.NETWORK_TYPE_EVDO_A -> "EVDO rev A"
TelephonyManager.NETWORK_TYPE_EVDO_B -> "EVDO rev B"
TelephonyManager.NETWORK_TYPE_GPRS -> "GPRS"
TelephonyManager.NETWORK_TYPE_GSM -> "GSM"
TelephonyManager.NETWORK_TYPE_HSDPA -> "HSDPA"
TelephonyManager.NETWORK_TYPE_HSPA -> "HSPA"
TelephonyManager.NETWORK_TYPE_HSPAP -> "HSPA+"
TelephonyManager.NETWORK_TYPE_HSUPA -> "HSUPA"
TelephonyManager.NETWORK_TYPE_IDEN -> "iDen"
TelephonyManager.NETWORK_TYPE_IWLAN -> "IWLAN"
TelephonyManager.NETWORK_TYPE_LTE -> "LTE"
TelephonyManager.NETWORK_TYPE_NR -> "NR (new radio) 5G"
TelephonyManager.NETWORK_TYPE_TD_SCDMA -> "TD_SCDMA"
TelephonyManager.NETWORK_TYPE_UMTS -> "UMTS"
else -> "[Unknown]"
}
The overrideNetworkType
, if available, provides more information for certain kinds of 4G and 5G connections. These are the options:
val overrideString = when(overrideNetworkType) {
TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA -> "5G non-standalone"
TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED -> "5G standalone (advanced)"
TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO -> "LTE Advanced Pro (5Ge)"
TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA -> "LTE (carrier aggregation)"
else -> null
}
To produce a human-readable network type, we can then use:
val netTypeString = overrideString ?: baseTypeString
Building this into a Kotlin + Flows + ViewModel + Compose app
I have used a Kotlin callbackFlow
inside a ViewModel to set up the above listeners. If you haven’t come across callbackFlow
before, it’s brilliant: a flow which can be used to create a listener on an external API when something signs up, and automatically removes the listener when collection is stopped.
I have converted the callbackFlow from a cold observable to a shared hot one using .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
. This prevents multiple DisplayInfoListeners
or PhoneStateListeners
from being created if multiple consumers sign up. The WhileSubscribed(5000)
part ensures that the observable sticks around for a short period after all consumers have disappeared, in case they are just about to reappear. (This happens for example in the case of screen rotation).
Within the Composable, I use collectAsStateWithLifecycle()
to ensure that the listeners are only active when the app is in the foreground. This is the architecture recommended by Manuel Vivo here.
The full app
The app is available on my GitHub. If you have any problems let me know here in the comments, or open an issue in repository.
Tom Colvin is CTO of Apptaura, the app development specialists; and Conseal Security, the mobile app security testing experts. Get in touch if I can help with any mobile security or development projects!