Tuesday, 23 August 2016

Programatically connecting to a WiFi network with Captive Portal on Marshmallow

Android platform is continuously growing and coming up with new concepts and features that are optimized to save battery or provide a better user experience. Each new Android update comes up with new APIs along with changes in older ones. At times these changes might need developers to handle additional scenarios to provide features that are supported in newer versions.

One such change was seen with an upgrade of Android OS to Lollipop and Marshmallow. With these upgrades, the system changed how it handles the connection to Networks. Starting from Lollipop, if a device is connected to both 3G and WiFi where WiFi network doesn't have internet connectivity, the device uses the 3G network to send data. This was done so that devices don't automatically start using WiFi network that has restrictions or has a captive portal to connect to the internet.

With start in active development in the IoT field, there might be use cases where a device might want to connect to a local WiFi network that doesn't provide internet. There might be other use cases where your app might want to connect to a WiFi network that is behind a captive portal and for automatically logging in to the network you might need to route your internet traffic through WiFi to make API calls needed to authenticate.

For such scenarios, Android does provide mechanisms for your app to explicitly use a particular network with specific capabilities to route all or parts of its network traffic.

Here is a scenario where an app wants to connect to a WiFi network (which is behind a captive portal) using it's SSID and switch to start using WiFi network.

*App on Marshmallow needs ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION permission and Location services should be enabled to get wifi scan results

Steps to follow (Assuming that WiFi on the device is enabled):
  1. Scan for new networks should be initiated to get the latest available networks. But in this case, we would assume that a particular network is already available, found during scan initiated by the system automatically. Create a network request and register for callback in which app should bind to the network when available. This would ensure that all the network traffic for the current app is routed through this network.
@TargetApi(LOLLIPOP) public void bindToNetwork(final String networkSSID, final NetworkStateChangeListener listener) { if (SDK_INT < LOLLIPOP) { logger.i("SDK version is below Lollipop. No need to bind process to network."); return; } NetworkRequest request = new NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(); networkCallback = networkCallback(networkSSID, listener); manager.registerNetworkCallback(request, networkCallback); } @TargetApi(LOLLIPOP) private NetworkCallback networkCallback(final String networkSSID, final NetworkStateChangeListener listener) { return new NetworkCallback() { @Override public void onAvailable(Network network) { NetworkInfo networkInfo = manager.getNetworkInfo(network); logger.i("Network is Available. Network Info: " + networkInfo); if (WifiHelper.areEqual(networkInfo.getExtraInfo(), networkSSID)) { manager.unregisterNetworkCallback(this); networkCallback = null; bindToRequiredNetwork(network); logger.i(format("Bound application to use %s network", networkSSID)); listener.onNetworkBound(); //do required stuff in this method } } }; } @TargetApi(LOLLIPOP) public void bindToRequiredNetwork(Network network) { if (SDK_INT >= M) { manager.bindProcessToNetwork(network); } else { ConnectivityManager.setProcessDefaultNetwork(network); } }
Now the callback would be received when the device is connected to the required network.
  1. Create an open WifiConfiguration with required SSID to which your app needs to connect. 
  2. Add the network to the list of configured networks by calling WifiManager.addNetwork(configuration)
A restriction that was put starting from Marshmallow was that the app can only modify those WiFi networks that it has created. The system doesn't allow modification of networks that other apps or users have created. So, adding the network might fail if the network already exists in the list (the system might auto connect to the network once it's detected). Additional steps are needed to handle such a scenario:
  1. Check networkId returned by addNetwork(), in case it is -1 (unable to add a network with configuration), try finding the network with the same SSID among the list of existing networks. 
  2. In case any network with the same SSID already exists, get networkId of that network.
  3. Ultimately, only call enableNetwork(networkId, true) if a valid networkId exists. I would never call enableNetwork(-1, true) on Marshmallow.

So, lets implement this:

public boolean connectToSSID(String SSID) { WifiConfiguration configuration = createOpenWifiConfiguration(SSID); int networkId = wifiManager.addNetwork(configuration); logger.d("networkId assigned while adding network is " + networkId); return enableNetwork(SSID, networkId); } private WifiConfiguration createOpenWifiConfiguration(String SSID) { WifiConfiguration configuration = new WifiConfiguration(); configuration.SSID = formatSSID(SSID); configuration.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE); assignHighestPriority(configuration); return configuration; } private boolean enableNetwork(String SSID, int networkId) { if (networkId == -1) { networkId = getExistingNetworkId(SSID); if (networkId == -1) { logger.e("Couldn't add network with SSID: " + SSID); return false; } } return wifiManager.enableNetwork(networkId, true); } private int getExistingNetworkId(String SSID) { List<WifiConfiguration> configuredNetworks = wifiManager.getConfiguredNetworks(); if (configuredNetworks != null) { for (WifiConfiguration existingConfig : configuredNetworks) { if (areEqual(trimQuotes(existingConfig.SSID), trimQuotes(SSID))) { return existingConfig.networkId; } } } return -1; } //To tell OS to give preference to this network private void assignHighestPriority(WifiConfiguration config) { List<WifiConfiguration> configuredNetworks = wifiManager.getConfiguredNetworks(); if (configuredNetworks != null) { for (WifiConfiguration existingConfig : configuredNetworks) { if (config.priority <= existingConfig.priority) { config.priority = existingConfig.priority + 1; } } } } private static String formatSSID(String wifiSSID) { return format("\"%s\"", wifiSSID); } private static String trimQuotes(String str) { if (!isEmpty(str)) { return str.replaceAll("^\"*", "").replaceAll("\"*$", ""); } return str; }

Calling connectToSSID() will connect the device to the network with given SSID. This will, in turn, provide a callback to onAvailable() of NetworkCallback in which the app would bind to this network. After binding is done, listener.onNetworkBound() is called in which a login request could be initiated.

Once login is done, to tell the device that internet connectivity is available now in this network so that it becomes an active network and all android network traffic is routed through this network, you would need to call

@TargetApi(LOLLIPOP) public void reportBoundNetworkConnectivity() { if (SDK_INT < LOLLIPOP) { return; } if (SDK_INT >= M) { Network defaultNetwork = manager.getBoundNetworkForProcess(); manager.reportNetworkConnectivity(defaultNetwork, true); } else { Network defaultNetwork = ConnectivityManager.getProcessDefaultNetwork(); manager.reportBadNetwork(defaultNetwork); } }

This will make the OS to check if there is internet connectivity available in the network to which app is bound. If there is, OS would switch to make it an active network. Lastly, the app should unbind from the network.

If you are looking to solve similar problems or need to connect to WiFi networks in your project for similar reasons, there is an open source Android library that provides all these functionalities and is easy to use. I would recommend you to give it a shot: android-wificonnect