Hilt / Jetpack / LiveData / ViewModel

SSID Connector – The Full App

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.

In this final article we’ll look at the app around the WifiConnector that we created in the first two articles in this series. The app uses the KTX, Lifecycle, ViewModel, LiveData, Navigation, and Hilt Jetpack components along with data binding. We won’t be covering these in any depth, but focusing on what benefits they add for this app.

Permissions

Let’s first discuss the permissions used by the app. Changing and viewing the connected network using WifiManager requires the ACCESS_WIFI_STATE and CHANGE_WIFI_STATE permissions. Conversely the doing the same with ConnectivityManager requires the CHANGE_NETWORK_STATE and ACCESS_NETWORK_STATE permissions instead. WifiConnector and its concrete subclasses require all of these. However we only need to declare them in the manifest as they are not dangerous permissions. We do not need to request runtime permissions here.

That said, we also need location permissions. This is because we can infer the device location from the Mac addresses of local WiFi networks. Google specifically uses Mac addresses to determine location. While the app will not crash if we do not have location permissions, the details of local networks will not include such fields as the Mac address, and the SSID – they’ll be empty. For this I have added a very simple runtime permissions implementation to obtain location permissions. This is visible in the code, but I won’t give an explanation here.

Hilt Dependency Injection

I have elected to use Hilt for dependency injection. Let’s first look at out ApplicationModule:

@Module
@InstallIn(ApplicationComponent::class)
object ApplicationModule {

    @Provides
    fun provideConnectivityManager(@ApplicationContext context: Context): ConnectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    @Provides
    fun provideWifiManager(@ApplicationContext context: Context): WifiManager =
        context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager

    @Provides
    fun provideWifiConnector(
        wifiManager: WifiManager,
        connectivityManager: ConnectivityManager
    ): WifiConnector =
        WifiConnector(wifiManager, connectivityManager)
}

Here we can a provider for WifiConnector which looks like a standard constructor call thanks to the use of the invoke() operator that we looked at previously. Both of its arguments are also provided here. Although I have used Hilt’s @ApplicationContext annotation to force the use of the Application Context, lint does not yet recognise this and gives a false warning that getSystemService(Context.WIFI_SERVICE) should only be used with an Application Context. For this reason, I called context.applicationContext to keep lint quiet, even though it should not be necessary!

ViewModel

We’ll now look at our ViewModel:

class NetworksViewModel @Inject constructor(
    private val wifiConnector: WifiConnector
) : ViewModel() {

    @ExperimentalCoroutinesApi
    val currentWifiSsid: LiveData = wifiConnector.connectivityFlow()
        .onStart { emit(wifiConnector.currentSsid) }
        .asLiveData(viewModelScope.coroutineContext)

    @ExperimentalCoroutinesApi
    val isSpecifiedWifiConnected: LiveData = currentWifiSsid.map { it == SSID }

    @ExperimentalCoroutinesApi
    fun toggleConnection() {
        viewModelScope.launch(Dispatchers.IO) {
            if (isSpecifiedWifiConnected.value == true) {
                wifiConnector.disconnect()
            } else {
                wifiConnector.connect(SSID, PASSPHRASE)
            }
        }
    }

    companion object {
        private const val SSID = BuildConfig.SSID
        private const val PASSPHRASE = BuildConfig.PRE_SHARED_KEY
    }
}

This is the direct consumer of our WifiConnector. The WifiConnector instance is injected in to the constructor.

Within the ViewModel there are two LiveData objects. The first consumes that connectivityFlow() that we created in the first article. the onStart is used to emit the current SSID as soon as we’re subscribed to the Flow. We could do this within connectivityFlow(), but I thought it interesting to show how we can emit an initial state at the point of consumption. We then call asLiveData() to handle this Flow as LiveData. It still blows my mind when I see how fluently this all works together. The Flow is created by a callbackFlow wrapper, and we can consume this in to LiveData.

The second LiveData object maps the first to a boolean which indicates whether or not we’re currently connected to the specified SSID.

There is also toggleConnection() method which will either connect to or disconnect from the specified SSID depending on whether we’re currently connected to it. We don’t perform any potentially blocking operations on the main thread, so we do this inside a launch block .

I should add that this lacks any kind of debouncing logic. The connection and disconnection operations will not be immediate, and it will take a second or two for the states to update. If the user was to tap the button again during this period it could cause some unexpected behaviour. However, I didn’t want to over-complicate the code, so I opted to leave that out.

I should explain that I chose not to include my personal Wifi SSID and password on a public Github repo, hence the values which are obtained from the BuildConfig. These actually come from outside the repo itself

The Layout

I mentioned earlier that I opted to use data binding, and we can see that within the layout:




    
        
    

    

        

        

.The currentWifiSsid field in the ViewModel controls the text for the TextView showing the current SSID name. Also the isSpecifiedWifiConnected field controls the button text. The onClick handler for the button invokes the toggleConnection() function in the ViewModel.

The Fragment

The last thing that we’ll look at is the Fragment that binds all of this together:

@AndroidEntryPoint
class NetworksFragment : Fragment() {

    private lateinit var binding: NetworksFragmentBinding

    @Inject
    lateinit var viewModel: NetworksViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = NetworksFragmentBinding.inflate(inflater, container, false)
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
        return binding.root
    }
}

There’s very little here. Hilt handles the provisioning of the ViewModel, and we only need to add the @AndroidEntryPoint and @Inject annotations to get it. In onCreateView() we inflate the binding object, and set its ViewModel variable. We also have to set the lifecycleOwner to the Fragment instance. If we fail to do this the binding will not subscribe to the live data within the ViewModel.

But that’s it. We have hidden all of the complexity for performing the network connectivity operations inside WifiConnector. The external API for that class has made the use of that API extremely simple and well integrated with modern Android app development best practice.

Conclusion

We would not be complete with seeing how this looks. Here it is on a Pixel 2XL running Android 11 Beta:

Here it is running on an Android Go Oreo (8.1) device:

The UX is slightly different but that it because there are very different things going on within the Android Framework. However the overall architecture of the app has very nice separation of concerns and leverages Jetpack components to make life even easier.

Arguably Rob’s question which prompted this series was answered by the second article of this series. However in putting together a test app to demonstrate it, I realised that there was a bigger story to tell. Hence the other two articles. So many thanks to Rob for asking the question which prompted all of this! Also, many thanks to Louis CAD for alerting me to the fact that the SendChannel offer() method will throw an exception if the channel is closed. The code in the first article has been updated accordingly.

The source code for this article is available here.

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