Connecting to Wi-Fi Devices With Rxjava in Android

Last year’s Android 10 announcement came with many new features like Gesture navigation, Dark theme, Location control and more. If your apps haven’t yet been migrated to the newest version, go ahead and do it! There have also been many changes at the API level, so check this link before switching to Android 10.
Well, enough announcements about Android 10 since Android 11 Beta is already out. 🤭
IoT (Internet of Things) is gaining popularity these days and working with Wi-Fi on Android can be challenging. Using BLE Technology is almost the same thing. When you just think that there are hundreds of different types of devices, different versions of Android, hardware, and so on, it’s already getting tougher. Testing of such use cases can be a bugger. Thanks to the open-source contributors and Google itself, we have lots of new test frameworks and stable APIs. If you are interested in BLE, there are some good articles written about it.
In this article, let’s look at the three most important parts of using Wi-FI APIs: Scanning for Wi-Fi devices, Connecting to a particular device, Communicating with a device and Disconnecting from a device using RxJava.
🚀 Setup
To set up the app in a state where all these parts work flawlessly, by being able to scan and connect to the Wi-Fi devices without a problem, we need to have some permissions and services allowed.
We must request for location permissions, enable the Wi-Fi Adapter and enable the Location Services, otherwise might not work.
Before accessing any of the code below, we should add these checks:
-
Enabling location permissions
On Marshmallow devices, we must request for location permission -Manifest.permission.ACCESS_FINE_LOCATION
. For that purpose, I usually use the PermissionDispatcher library, but there many other libraries you can use (or just write your own code using the Android docs). -
Enabling Wi-Fi adapter
Here you can check if the Wi-Fi adapter is enabled and force the user to enable it before continuing to the next step. There are other options where you can enable it from the code directly, but I don’t recommend it, as you are not letting the user know that you are enabling the Wi-Fi adapter. And it no longer works on Android 10. 😒
fun isWiFiEnabled(): Boolean { | |
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager | |
return wifiManager.isWifiEnabled | |
} |
- Enabling Location Services
For this purpose, I’m using Google Play Services library. It shows a popup where the user can enable these services by clicking accept. Just add this dependency in your project.
implementation 'com.google.android.gms:play-services-location:17.0.0'
The onSuccessCallback
is called only if the user clicks accept.
fun checkLocationSettings(onSuccessCallback: () -> Unit) { | |
val locationRequest = LocationRequest.create() | |
locationRequest.priority = LocationRequest.PRIORITY_LOW_POWER | |
val builder = LocationSettingsRequest.Builder() | |
builder.addLocationRequest(locationRequest) | |
val client = LocationServices.getSettingsClient(this) | |
val task = client.checkLocationSettings(builder.build()) | |
task.addOnSuccessListener(this) { | |
onSuccessCallback() | |
} | |
task.addOnFailureListener(this) { | |
if (it is ResolvableApiException) { | |
// Location settings are not satisfied, but this can be fixed | |
// by showing the user a dialog. | |
try { | |
// Show the dialog by calling startResolutionForResult(), | |
// and check the result in onActivityResult(). | |
it.startResolutionForResult(this, 111) | |
} catch (sendEx: IntentSender.SendIntentException) { | |
// Ignore the error. | |
} | |
} | |
} | |
} |
In Kotlin you can use it like this:
checkLocationSettings {
// the user has allowed the locating services.
}
We now have all these services and permissions enabled, and we can continue communicating with the device.
🔎 1. Scanning for Wi-Fi devices
Scanning for a device is very simple. In order to wrap the broadcast receiver so it becomes reactive, we use the Completable.create
method from RxJava.
After registering a broadcast receiver with a filter SCAN_RESULTS_AVAILABLE_ACTION
, just make sure to filter out the right SSID from the result you are looking for and emit onComplete
if the SSID is right. Otherwise emmit tryOnError
. (We use tryOnError
, just because if the Rx flow is cancelled before the scanning is finished, we don’t get the UndeliverableException
).
class WiFiScanUseCase(private val context: Context) { | |
private val wifiManager = | |
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager | |
private val wifiSSID = "My Network" | |
operator fun invoke(): Completable { | |
return startScanning() | |
} | |
private fun startScanning(): Completable { | |
return Completable.create { emitter -> | |
val intentFilter = IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) | |
val receiver = object : BroadcastReceiver() { | |
override fun onReceive(ctx: Context?, intent: Intent?) { | |
val resultList = wifiManager.scanResults.toList() | |
val cmrList = resultList.filter { it.SSID == wifiSSID } | |
Timber.v("Filtered list: ${cmrList.map { it.SSID }}") | |
when { | |
cmrList.size == 1 -> emitter.onComplete() | |
cmrList.isEmpty() -> emitter.tryOnError(IllegalArgumentException("Can not find a device.")) | |
else -> emitter.tryOnError(IllegalArgumentException("More than one items found.")) | |
} | |
} | |
} | |
Timber.v("Scanning for wifi networks...") | |
context.registerReceiver(receiver, intentFilter) | |
emitter.setCancellable { | |
Timber.v("Unregistering scanning for wifi networks...") | |
context.unregisterReceiver(receiver) | |
} | |
if (!wifiManager.startScan()) { | |
Timber.w("error startScan") | |
emitter.onError(IllegalArgumentException("Failed to start scan")) | |
} | |
} | |
} | |
} |
❤️ 2. Connecting to a device
Now, after scanning has been done successfully, we can connect to the device as we know that it is nearby.
For older APIs from Android 9 and below:
We need to wrap the broadcast receiver as we did before with Completable.create
and register the callback with a filter for listening network changes WifiManager.NETWORK_STATE_CHANGED_ACTION
. After calling WifiManager#addNetwork
and then [WifiManager#enableNetwork
](https://developer.android.com/reference/android/net/wifi/WifiManager#enableNetwork(int,%20boolean), the broadcast receiver receives a response when the connection is being changed, so we need to check the right SSID as long as the device connects. We finish it by calling onComplete
.
For further communication with the device using REST API, socket or whatever the device supports, we need to have this Network instance accessible. We can return the Network instance like this:
val network: Network? = connectivityManager.allNetworks.find { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
connectivityManager.getNetworkCapabilities(it) | |
.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) | |
} else { | |
connectivityManager.getNetworkInfo(it).extraInfo == wifiSSID | |
} | |
} |
class WiFiConnectLegacyUseCase(private val context: Context) { | |
private var wifiManager = | |
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager | |
private val connectivityManager = | |
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
private val wifiSSID = "My Network" | |
private val wifiPassword = "password1234" | |
operator fun invoke(): Single<Network> { | |
return connect() | |
.delay(5, TimeUnit.SECONDS) | |
.timeout(20, TimeUnit.SECONDS) | |
.andThen(Single.defer { initNetwork() }) | |
} | |
private fun connect(): Completable { | |
return Completable.create { emitter -> | |
val intentFilter = IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION) | |
val receiver = object : BroadcastReceiver() { | |
override fun onReceive(context: Context?, intent: Intent?) { | |
if (isConnectedToCorrectSSID()) { | |
Timber.v("Successfully connected to the device.") | |
emitter.onComplete() | |
} else { | |
Timber.w("Still not connected to ${wifiSSID}. Waiting a little bit more...") | |
} | |
} | |
} | |
Timber.v("Registering connection receiver...") | |
context.registerReceiver(receiver, intentFilter) | |
emitter.setCancellable { | |
Timber.v("Unregistering connection receiver...") | |
context.unregisterReceiver(receiver) | |
} | |
addNetwork(emitter) | |
} | |
} | |
private fun initNetwork(): Single<Network> { | |
Timber.v("Initializing network...") | |
return Single.just(connectivityManager.allNetworks.find { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
connectivityManager.getNetworkCapabilities(it) | |
.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) | |
} else { | |
connectivityManager.getNetworkInfo(it).extraInfo == wifiSSID | |
} | |
}) | |
} | |
private fun addNetwork(emitter: CompletableEmitter) { | |
Timber.v("Connecting to ${wifiSSID}...") | |
val wc = WifiConfiguration() | |
wc.SSID = "\"" + wifiSSID + "\"" | |
wc.preSharedKey = "\"" + wifiPassword + "\"" | |
wc.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK) | |
val netId = wifiManager.addNetwork(wc) | |
if (netId != -1) { | |
if (!wifiManager.enableNetwork(netId, true)) { | |
Timber.e("Failed to connect to the device.") | |
emitter.tryOnError(IllegalArgumentException("Failed to connect to the device")) | |
} | |
} else { | |
Timber.e("Failed to connect to the device. addNetwork() returned -1") | |
emitter.tryOnError(IllegalArgumentException("Failed to connect to the device. addNetwork() returned -1")) | |
} | |
} | |
private fun isConnectedToCorrectSSID(): Boolean { | |
val currentSSID = wifiManager.connectionInfo.ssid ?: return false | |
Timber.v("Connected to $currentSSID") | |
return currentSSID == "\"${wifiSSID}\"" | |
} | |
} |
For newer APIs running Android 10 and later:
By implementing the WifiConnectUseCase
you will provide support for devices on Android 10 and above. Just make sure you have targedSdkVersion 29
in the app’s build.gradle
file.
This method is similar to the previous one, but instead of a broadcast receiver, it uses callbacks. The only difference here is that after calling a ConnectivityManager#requestNetwork
, it triggers a popup where the user should agree to be connected to the given Wi-Fi network.
Getting a response onAvailable
in the callback means that the app is successfully connected to the device. If we get a onUnavailable
, it usually means that the user has denied the popup.
class WiFiConnectUseCase(context: Context) { | |
private var wifiManager = | |
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager | |
private val connectivityManager = | |
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
private val wifiSSID = "My Network" | |
private val wifiPassword = "password1234" | |
@RequiresApi(api = Build.VERSION_CODES.Q) | |
operator fun invoke(): Single<Pair<Network, ConnectivityManager.NetworkCallback>> { //return the NetworkCallback in order to disconnect properly from the device | |
return connect() | |
.delay(3, TimeUnit.SECONDS) // wait for 3 sec, just to make sure everything is configured on the device | |
} | |
@RequiresApi(Build.VERSION_CODES.Q) | |
private fun connect(): Single<Pair<Network, ConnectivityManager.NetworkCallback>> { | |
return Single.create { emitter -> | |
val specifier = WifiNetworkSpecifier.Builder() | |
.setSsid(wifiSSID) | |
.setWpa2Passphrase(wifiPassword) | |
.build() | |
val networkRequest = NetworkRequest.Builder() | |
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI) | |
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) // as not internet connection is required for this device | |
.setNetworkSpecifier(specifier) | |
.build() | |
val networkCallback = object : ConnectivityManager.NetworkCallback() { | |
override fun onAvailable(network: Network) { | |
super.onAvailable(network) | |
Timber.v("connect to WiFi success. Network is available.") | |
emitter.onSuccess(Pair(network, this)) | |
} | |
override fun onUnavailable() { | |
super.onUnavailable() | |
Timber.w("connect to WiFi failed. Network is unavailable") | |
emitter.tryOnError(IllegalArgumentException("connect to WiFi failed. Network is unavailable")) | |
} | |
} | |
connectivityManager.requestNetwork(networkRequest, networkCallback) | |
} | |
} | |
} |
So, before calling any of these use cases make sure you check the current Android SDK version like this:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q){
// use WiFiConnectUseCase.kt
} else {
// use WiFiConnectLegacyUseCase.kt
}
📡 3. Communicating with a device
Let’s say the device doesn’t provide an internet connection and your phone has LTE ON. That means your phone will still be connected to the Wi-Fi device, but all the requests will go through the LTE channel even though you are connected to the right device.
You must bind the network after a successful connection to make sure communication stays on the right channel.
That is why you need to explicitly use ConnectivityManager#bindProcessToNetwork(network)
in order to make sure your communication stays on the right process.
Binding to a network:
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
connectivityManager.bindProcessToNetwork(network) | |
} else { | |
ConnectivityManager.setProcessDefaultNetwork(network) | |
} |
When you are done communicating, unbind from the network by passing a null
value. This action lets the device to decide for itself which network to use as a primary internet connection (usually, the mobile-LTE network is faster to connect to). If the phone is not using LTE, it needs a couple of seconds to reconnect to the original Wi-Fi which was using before.
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
connectivityManager.bindProcessToNetwork(null) | |
} else { | |
ConnectivityManager.setProcessDefaultNetwork(null) | |
} |
Accessing the device
If your device supports REST API, you can simply use Retrofit to communicate with the device as you did before with any other REST API.
Usually, the API URL will be something like a raw IP, for example, https://192.168.1.20. Newer versions of Android will reject these calls and you will get a ERR_CLEARTEXT_NOT_PERMITTED error, as these calls are considered not safe. In order to fix this, follow this article.
💔 4. Disconnecting from a device
There are two types of disconnecting from a Wi-Fi connection, depending on whether it is the new (Android 10 and above) or the old API.
For the newer API (Android 10) pass the networkCallback you want to unregister, that you got from the WiFiConnectUseCase:
connectivityManager.unregisterNetworkCallback(networkCallback)
For older APIs (below Android 10), just pass thenetworkIdyou want to remove, that you got from the WiFiConnectLegacyUseCase:
wifiManager.removeNetwork(networkId)
Here is the code, which supports both cases:
class WiFiDisconnectUseCase(context: Context) { | |
private var wifiManager = | |
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager | |
private val connectivityManager = | |
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
private val wifiSSID = "My Network" | |
operator fun invoke(networkCallback: ConnectivityManager.NetworkCallback? = null) { //we send the NetworkCallback as parameter from Android 10 and above | |
if (wifiManager.connectionInfo.ssid != "\"$wifiSSID\"") { | |
Timber.w("Skipping disconnect. The device is not connected to $wifiSSID.") | |
return | |
} | |
networkCallback?.apply { | |
Timber.v("unregistering network callback") | |
connectivityManager.unregisterNetworkCallback(this) | |
} | |
val networkId = wifiManager.connectionInfo.networkId | |
Timber.v("Disconnecting from ${wifiManager.connectionInfo.ssid}...") | |
if (networkId != -1) { | |
return if (!wifiManager.removeNetwork(networkId)) { | |
Timber.e("Failed to remove network ${wifiSSID}.") | |
} else { | |
Timber.v("Successfully removed network ${wifiSSID}.") | |
} | |
} else { | |
Timber.w("Cannot remove network with id -1") | |
} | |
} | |
} |
Extra: Using it in a ViewModel
Using these use cases into a ViewModel is pretty straight forward. Hopefully, you are already familiar with the use of ViewModels and MVVM architecture.
class WifiConnectionViewModel( | |
private val context: Context, | |
wifiScanUseCase: WiFiScanUseCase, | |
private val wifiConnectUseCase: WiFiConnectDockUseCase, | |
private val wifiConnectLegacyUseCase: WiFiConnectDockLegacyUseCase, | |
private val wifiDisconnectUseCase: WiFiDisconnectDockUseCase | |
) : ViewModel() { | |
private val disposables = CompositeDisposable(); | |
private var networkCallback: ConnectivityManager.NetworkCallback? = null | |
init { | |
disposables.add( | |
findDockUseCase() | |
.andThen(connect()) | |
.doOnSuccess { lockNetwork(it) } | |
// here, once connected, we usually communicate with the device | |
.doOnSuccess { unlockNetwork() } | |
.doOnTerminate { disconnectDockUseCase(networkCallback) } | |
.doOnTerminate { networkCallback = null } | |
.subscribe({ | |
Timber.v("Successfully finished test.") | |
}, { | |
Timber.v("Test failed: ${it.message}") | |
}) | |
) | |
} | |
private fun connect(): Single<Network> { | |
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | |
connectDockUseCase() | |
.doOnSuccess { networkCallback = it.second } | |
.map { it.first } | |
} else { | |
connectDockLegacyUseCase() | |
} | |
} | |
private fun lockNetwork(it: Network) { | |
Timber.v("Binding process to network") | |
context.bindProcessToNetwork(it) | |
} | |
private fun unlockNetwork() { | |
Timber.v("Unbinding process to network") | |
context.unbindProcessToNetwork() | |
} | |
override fun onCleared() { | |
super.onCleared() | |
disposables.clear() | |
} | |
} | |
/** | |
* If the network doesn't have internet connectivity network, requests will not be routed to it. | |
* To direct all the network requests from your app to an external Wi-Fi device bind a network using | |
* this function and all traffic will be routed to this network even if it has not internet connection. | |
* | |
* Call Context#unbindProcessToNetwork an extension function in order to unbind. | |
*/ | |
fun Context.bindProcessToNetwork(network: Network) { | |
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
connectivityManager.bindProcessToNetwork(network) | |
} else { | |
ConnectivityManager.setProcessDefaultNetwork(network) | |
} | |
} | |
/** | |
* To unbind direction of all network requests and let the system decide which the traffic channel | |
* will use if there is no internet connection. | |
*/ | |
fun Context.unbindProcessToNetwork() { | |
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
connectivityManager.bindProcessToNetwork(null) | |
} else { | |
ConnectivityManager.setProcessDefaultNetwork(null) | |
} | |
} |
💪 Conclusion
Working on Wi-Fi devices can give us some hard time and it takes a lot of patience. Thankfully, the APIs these days are more descriptive, easy to use and the Android developer’s documentation is well written too.
Please feel free to leave a comment below. If you have any issues with the code, I’d be happy to help. And, if you are not using RxJava in your case, there is a great article on how to connect to devices using Android 10.
Cheers! 🍻