Handle disabled mobile data setting on iOS

February 13, 2019 – Thomas Senkman – 8-minute read

For an unknown reason, a significant number of users disable the mobile data for our iOS app. This is not a problem when they are booking a car from their couch at home with Wi-Fi, but can quickly become a major issue when they try to unlock their Drivy Open car in the street. At this specific moment, there is very little chance that they remember they disabled this setting, so it very often leads to a call to Customer Services that could have been avoided.

Drivy's settings with mobile data switched off
Drivy's settings with mobile data switched off

If we do nothing about this, the users’s resquests would always fail when not on Wi-Fi, and they would see a default error. In our case, they would see the message “An error has occurred”, which doesn’t help them to understand what’s wrong. However, it’s our job to let the users know what they can do to fix the issue.

iOS native implementation

To try to solve this, according to our tests, iOS shows an alert when all these conditions are met:

iOS native alert
iOS native alert

Its UX is nice, because it redirect the user to the correct screen to update the setting with a single tap. But this doesn’t cover all the cases: if a first Wi-Fi call is successful, even with the mobile data setting disabled, the user will never see the alert.

Because we think it’s not sufficient, we decided to reimplement this alert for each network call that fails because of this specific setting.

Let’s code this

Requirements

The only requirement is to have an iOS 9 app target, as we will use CTCellularData which is only available from this version. To our knownledge there is unfortunately no way to check the network-data setting value before.

Used tools

So since iOS 9, Apple provides in CTCellularData a listener to check the value of the mobile data switch for the current app.

We’ll also need to be able to check for reachability to know if we are in our specific error case. As we use Alamofire in our app, we used their NetworkReachabilityManager, but you can also use another solution like Reachability.swift or even add Apple’s SCNetworkReachability class to your app for this.

Getting the current mobile data setting value

Implementing the listener to get the current value is pretty straightforward:

import CoreTelephony

//...

let cellularData = CTCellularData()
cellularData.cellularDataRestrictionDidUpdateNotifier = { (state) in
  print(state)
}

Getting the reachability status

Same logic here, we just have to explicitly start the listener after setting it:

import Alamofire

// ...

let networkReachabilityManager = NetworkReachabilityManager()
networkReachabilityManager?.listener = { [weak self] state in
  print(state)
}    
networkReachabilityManager?.startListening()

Data availability

Checking reachability can be done at any moment, but is not instant. So if you init the NetworkReachabilityManager and directly try to get the current status, this will probably fail. To avoid this, and because this does not consume a great deal of memory, we can have our own manager that stores the current value whenever it changes:

import Alamofire

class ApiReachabilityManager {

  private var currentNetworkReachabilityState: NetworkReachabilityManager.NetworkReachabilityStatus = .unknown

  init() {
      let networkReachabilityManager = NetworkReachabilityManager()
      networkReachabilityManager?.listener = { [weak self] state in
        self?.currentNetworkReachabilityState = state
      }    
    networkReachabilityManager?.startListening()
  }
}

When to make the checks

We strongly advise to check both statuses, especially reachability, only when network errors happen. You should never check for reachability before making a network call to potentially avoid making it. If there is an issue with the reachability, this would result in blocking all the network calls of your app. This is even the first “important thing” Alamofire says in their documentation:

Do NOT use Reachability to determine if a network request should be sent. You should ALWAYS send it.

Final implementation

Here is our singleton manager, which contains:

import Foundation
import CoreTelephony
import Alamofire

class ApiReachabilityManager {
  static let shared = ApiReachabilityManager()
  
  private let networkReachabilityManager = NetworkReachabilityManager()
  private let cellularData = CTCellularData()
  
  private var currentNetworkReachabilityState: NetworkReachabilityManager.NetworkReachabilityStatus = .unknown
  private var currentCellularDataState: CTCellularDataRestrictedState = .restrictedStateUnknown
  
  func start() {
    cellularData.cellularDataRestrictionDidUpdateNotifier = { [weak self] (state) in
      self?.currentCellularDataState = state
    }
    
    networkReachabilityManager?.listener = { [weak self] state in
      self?.currentNetworkReachabilityState = state
    }
    
    networkReachabilityManager?.startListening()
  }
  
  func checkApiReachability(viewController: UIViewController?, completion: (_ restricted: Bool) -> Void) {
    let isRestricted = currentNetworkReachabilityState == .notReachable && currentCellularDataState == .restricted
    
    guard !isRestricted else {
      if let viewController = viewController {
        presentReachabilityAlert(on: viewController)
      }
      completion(true)
      return
    }
    
    completion(false)
  }
  
  private func presentReachabilityAlert(on viewController: UIViewController) {
    let alertController = UIAlertController(
      // TODO: replace YOUR-APP by your app's name
      title: "Mobile Data is Turned Off for \"YOUR-APP\"",
      message: "You can turn on mobile data for this app in Settings.",
      preferredStyle: .alert
    )
    if let settingsUrl = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(settingsUrl) {
      alertController.addAction(
        UIAlertAction(title: "Settings", style: .default, handler: { action in
            UIApplication.shared.open(settingsUrl)
        })
      )
    }
    
    let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
    alertController.addAction(okAction)
    alertController.preferredAction = okAction
    
    viewController.present(alertController, animated: true, completion: nil)
  }
}

We need to start the listeners at some point, we’ve chosen to do it directly at app launch, in AppDelegate since our app needs network calls directly:

ApiReachabilityManager.shared.start()

Then, in your view controller, you can simply call the checkApiReachability method in case of error:

func handleError(_ error: Error) {
  ApiReachabilityManager.shared.checkApiReachability(viewController: self) { (restricted) in
    if !restricted {
      // TODO: continue to handle error, there is no network-data issue
    }
    // No need to handle else case as alert has been presented if needed
  }
}

Conclusion

It’s kind of strange to reimplement a native alert, but we were really surprised by iOS’s incomplete basic version, which isn’t that much of a help in our case. We only did this recently so we don’t have enough data to draw conclusions, but we hope this will avoid some calls to Customer Services.

And don’t forget to never rely on reachability before making the actual network call: it should always be an error handling helper.

Did you enjoy this post? Join Drivy's engineering team!
View openings