July 22, 2020

1246 words 6 mins read

Connecting to Wi-Fi Devices With Rxjava in Android

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
}
}
view raw FindNetwork.kt hosted with ❤ by GitHub
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! 🍻