Coroutines / Flow / Kotlin

SSID Connector – Callback Flow

Recently a friend and former colleague Robert Sproats approached me regarding some issues he was having. He asked if I was aware of best practices for connecting to a specific WiFi SSID on modern and legacy devices. Android Q deprecated the WifiManager APIs for this and they no longer work for Q and later devices. There are some newer APIs to do the same thing. I haven’t personally seen any best practice for handling both mechanisms documented. This three part series covers my approach to doing this using modern Android development techniques.

Before Android Q the way to connect to a specific SSID was by using NetworkManager‘s enableNetwork() method. In Android Q this no longer works, and we must use ConnectivityManager‘s requestNetwork() instead. There is documentation for both of these, so I’ll skip a detailed explanation of how they work. You’ll see the code I use for it in the next article.

We’ll create a simple app which displays the name of the currently connected SSID. The app will allow us to force a connection to a different SSID.

Connectivity Changes

The app will require details of the currently connected SSID. The method for detecting this has not changed in Android Q but there is a nice technique that we can use to expose changes: Flows. Asynchronous Flows are part of the Kotlin Coroutines framework. A Flow will emit a series of values asynchronously. They are similar to an RxJava Observable. Once again there is plenty of good documentation of Flow around, so I’ll avoid a lengthy explanation here. Instead we’ll focus on a really useful mechanism to create a Flow from a callback API.

sealed class WifiConnector constructor(
    protected val wifiManager: WifiManager,
    protected val connectivityManager: ConnectivityManager
) {

    @ExperimentalCoroutinesApi
    fun connectivityFlow(): Flow = callbackFlow {

        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                super.onAvailable(network)
                sendBlocking(currentSsid)
            }

            override fun onLost(network: Network) {
                super.onLost(network)
                sendBlocking(currentSsid)
            }
        }
        val request = NetworkRequest.Builder()
            .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
            .build()
        connectivityManager.registerNetworkCallback(request, callback)

        awaitClose {
            connectivityManager.unregisterNetworkCallback(callback)
        }
    }
    .
    .
    .
}

The containing class is actually a sealed class and the reason for this will become apparent later on. The connectivityFlow() function is where the magic happens. This flow will emit the name of the current SSID whenever the connected network changes.

The callbackFlow() method allows us to create a Flow from a callback-based API. It is still experimental, so we must use the @ExperimentalCoroutinesApi annotation to avoid warnings. It takes a single lambda argument, and the receiver of this lambda is a ProducerScope<T>. The type T is inferred from the type used for the Flow that is returned – in this case String. The ProducerScope is a CoroutineScope that wraps a SendChannel. This SendChannel allows us to emit values and these are propagated to the Flow.

NetworkCallback

The callback object is an implementation of ConnectivityManager.NetworkCallback. The onAvailable() and onLost() methods both emit the name of the current SSID using the offer() method. This is one of the methods of the SendChannel and is available because here because the lambda receiver is ProducerScope which implements SendChannel. We are calling a method on the ProducerScope here. If we move this callback implementation outside of the lambda, then this will no longer be available and we’ll get errors. For more complex callback interfaces, we could add a SendChannel argument to the constructor to move it outside the lambda:

sealed class WifiConnector constructor(
    protected val wifiManager: WifiManager,
    protected val connectivityManager: ConnectivityManager
) {

    private inner class Callback(private val sendChannel : SendChannel) :
        ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            super.onAvailable(network)
            runCatching { sendChannel.offer(currentSsid) }
        }

        override fun onLost(network: Network) {
            super.onLost(network)
            runCatching { sendChannel.offer(currentSsid) }
        }
    }
    
    @ExperimentalCoroutinesApi
    fun connectivityFlow(): Flow = callbackFlow {
        val callback = Callback(this)
        
        val request = NetworkRequest.Builder()
            .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
            .build()
        connectivityManager.registerNetworkCallback(request, callback)

        awaitClose {
            connectivityManager.unregisterNetworkCallback(callback)
        }
    }
    .
    .
    .
}

This is functionally identical to the earlier code, but shows the scope of the offer() method a little more clearly.

The offer() method is wrapped in a runCatching block because offer() will throw an exception if the channel is closed. In this case we’ll just suppress it but it might be something that we could use to trigger any cleanup. Many thanks to Louis CAD for informing me of this.

We construct a NetworkRequest object through which we specify that we’re only interested in Wifi connectivity changes. Then we register this callback with ConnectivityManager using the registerNetworkCallback() method.

The Joy of callbackFlow

It is not particularly obvious from this that the body of the lambda is not executed immediately. The Flow object returned is a cold stream so will do nothing until it has at least one active subscriber. The lambda body will be executed only when the Flow has at least one active subscriber.

awaitClose is an extension function to ProducerScope which will perform cleanup. It can be triggered either because we one of our callback methods has called close() on the SendChannel, or if there are no longer any active subscribers to the Flow.

Current SSID

This final part of this block is how we determine the current SSID from within the callback methods. This is quite short, but there’s a fair bit going on:

sealed class WifiConnector constructor(
    protected val wifiManager: WifiManager,
    protected val connectivityManager: ConnectivityManager
) {
    .
    .
    .
    val currentSsid: String
        get() = wifiManager.connectionInfo
            ?.takeIf { it.supplicantState == SupplicantState.COMPLETED }
            ?.ssid
            ?.replace("\"", "")
            ?: ""
    .
    .
    .
}

First we get the connectionInfo from WifiManager. Next we filter this based upon the supplicant state and only take the connectionInfo if it is COMPLETED (i.e. fully connected). After that we obtain the SSID from the connectionObject. Finally we strip out any quotes from the string. Each of these stages is protected using the safe call operator (?.) and there is a final Elvis operator (:?) as the fallback if anything returns null.

We only want to emit the SSID of fully connected Wifi networks hence the supplicant state check. We also need to strip out of the quote characters because the SSID name will be delimited with quotes. Consumers may not be aware of this.

If anything fails then we emit <None> thanks to the Elvis operator fallback.

Conclusion

There’s not much code here but the concepts may be unfamiliar to some. The benefits of this may not be obvious at this point. But please bear with me, in the third article we’ll see how easy it is to consume SSID changes.

I usually like to publish working code with each article but in this case we only have a producer and not the consumer code. Everything we have covered so far is actually in the code snippets, so that will suffice for now. I will publish the full source with the concluding article, I promise.

In the next article we’ll turn our attention to the connecting and disconnecting from a specific SSID.

© 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.

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.