Track GPS position with iOS app killed

I want to share a simple how-to, used to get the GPS position of an iOS user with the app completely closed, in the background, and in the foreground without using any useless workaround like idle timer and background operations…

We discuss about the GEO-fence tecnique. You can read more on Apple reference:

https://developer.apple.com/documentation/corelocation/monitoring_the_user_s_proximity_to_geographic_regions


Intro

As iOS developer, you know that you can’t play with your app content when is closed. Partially true…

You can execute some code with app completely closed only for these conditions:

  1. Monitoring a geo-fence defined area
  2. Monitoring a defined bluetooth service CBUUID and communicate with it
  3. Receive iBeacon from bluetooth device
  4. Execute some code when receiving a push-notifications, also silently

In this tutorial, we discover the first point. See blog posts for the other points explained.


How it works?

In order to use the GEO-fence you need to declare in your code the area you want to track. An area is a CLLRegion defined with a couple of coordinates (latitude and longitude) and a radius in meter.

You can define 250 max area.

You position tracking is managed now from the operating system and not from your app (because is closed…).

When your device enter (or exit) from a defined region, a callback in your code is called. In particular two delegates are called:

func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {}
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {}

If your app use the geo-fence, you should see in the status bar of your phone a new icon, like this, an empty arrow:

And also your tracking mode should be setted to “EVER“.


Infinite geo-fence regions

To track the user location with the app closed, you need to play a little bit with the regions.

A simple but very useful trick is to:

  • create a new CLRegion based on your current position when open the app
  • in the didExit callback, create a new region with the position of the user in that moment
  • in this way you can repeat this cycle forever

Let’s implementing this. An example is better.


Getting GPS position using geo-fence with app closed

Prepare your project

Open XCode, create a new project and enable the Background Modes:

  • Location updates

We also add some UNUserNotificationCenter in order to get notified when something with your app closed happened (like a notification-log). This because you cannot debug it… of course it’s closed.


Implementing the UNUserNotificationCenter scheduler:

Create your extension, your manager, or place this code in the AppDelegate, a simple configuration for UNUserNotificationCenter scheduler:

import UIKit
import UserNotifications
extension AppDelegate: UNUserNotificationCenterDelegate {
    
    func registerNotifications() {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert,.badge,.sound]) { (granted:Bool, error:Error?) in
            if error != nil { return }
            DispatchQueue.main.async {
                UIApplication.shared.registerForRemoteNotifications()
            }
        }
        UNUserNotificationCenter.current().delegate = self
    }
        
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.alert, .badge, .sound])
    }
    
    func scheduleLocalNotification(alert:String) {
        let content = UNMutableNotificationContent()
        let requestIdentifier = UUID.init().uuidString
        
        content.badge = 0
        content.title = "Location Update"
        content.body = alert
        content.sound = UNNotificationSound.default
        
        let trigger = UNTimeIntervalNotificationTrigger.init(timeInterval: 1.0, repeats: false)       
        let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
        UNUserNotificationCenter.current().add(request) { (error:Error?) in            
            print("Notification Register Success")
        }
    }
    
}

We have now the notification scheduler configured.

Come back in the AppDelegate and in the

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { }

Call the registerNotifications() method. You’ll receive a privacy alert that informs you that the app wants to send push notifications.

Make a test, should works as expected.


Implementing the CoreLocation manager:

As before, create a new manager or copy/paste to your AppDelegate.swift if you prefer. This class is responsible of getting authorization and GPS position of the user:

import UIKit
import CoreLocation
extension AppDelegate: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if status == .authorizedAlways || status == .authorizedWhenInUse {
            manager.startUpdatingLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        let location = locations.last
        if (location?.horizontalAccuracy)! <= Double(65.0) {
            myLocation = location
            print ("we add something later...")
        } else {
            manager.stopUpdatingLocation()
            manager.startUpdatingLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
        scheduleLocalNotification(alert: "didEnterRegion")
    }
    func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
        scheduleLocalNotification(alert: "didExitRegion")
        manager.stopMonitoring(for: region)
        manager.startUpdatingLocation()
    }
    
    func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
        scheduleLocalNotification(alert: error.localizedDescription)
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        scheduleLocalNotification(alert: error.localizedDescription)
    }
    
}

Well, we have also the CoreLocation manager ready to use.

You can instanciate in your class:

var locationManager: CLLocationManager? = CLLocationManager()

Managing the CLRegion(s)

I’ve created an helper function to create your CLRegion, this one:

func createRegion(location:CLLocation?) {
    if CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) {
        let coordinate = CLLocationCoordinate2DMake((location.coordinate.latitude), (location.coordinate.longitude))
        let regionRadius = 100.0
        let coords = CLLocationCoordinate2D(latitude: coordinate.latitude, longitude: coordinate.longitude)
        let region = CLCircularRegion(center: coords, radius: regionRadius, identifier: "aabb")
        region.notifyOnEntry = true
        region.notifyOnExit  = false
        scheduleLocalNotification(alert: "Region Created \(location.coordinate) with \(location.horizontalAccuracy)")
        self.locationManager?.stopUpdatingLocation()
        self.locationManager?.startMonitoring(for: region)
    }
}

To make it works you should also implement the CoreLocation manager and a custom CLLocation (the user position):

var locationManager: CLLocationManager? = CLLocationManager()
var myLocation: CLLocation?

Now, go in your didFinishLaunchingWithOptions and initialize the locationManager controlling the status of the application with launchOptions:

if launchOptions?[UIApplication.LaunchOptionsKey.location] != nil {
    if locationManager == nil {
        locationManager = CLLocationManager()
    } else {
        locationManager = nil
        locationManager = CLLocationManager()
    }
    locationManager?.delegate = self
    locationManager?.distanceFilter = 10
	locationManager?.desiredAccuracy = kCLLocationAccuracyBest
    locationManager?.allowsBackgroundLocationUpdates = true
    locationManager?.startUpdatingLocation()
} else {
    locationManager?.delegate = self
    locationManager?.distanceFilter = 10
    locationManager?.desiredAccuracy = kCLLocationAccuracyBest
    locationManager?.allowsBackgroundLocationUpdates = true
    if CLLocationManager.authorizationStatus() == .notDetermined {
        locationManager?.requestAlwaysAuthorization()
    }
    else if CLLocationManager.authorizationStatus() == .authorizedWhenInUse {
        locationManager?.requestAlwaysAuthorization()
    }
    else if CLLocationManager.authorizationStatus() == .authorizedAlways {
        locationManager?.startUpdatingLocation()
    }
}

We start and restart the location and check also the authorization status.

If the status is authorizedAlways we can start monitoring the locations.

Another important this is to create your region when the app enter in background (before closing):

func applicationDidEnterBackground(_ application: UIApplication) {
    self.createRegion(location: myLocation)
}

One more thing, replace the line “we add something later” with the creation of a region, like this:

if !(UIApplication.shared.applicationState == .active) {
    self.createRegion(location: location)
}

Now you have the updated position every time.

You can also call an API instead of launching notifications of course. Notifications are useful to “debug” without debug attached.


Remember also to add in the Info.plist the privacy settings (else the app crash on startup):

<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>App wants to use location in background</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>App wants to use location in background</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>App wants to use location in background</string>

Now you can run the app and accept all the popups that you receive:

  • push notifications
  • gps position always

Your app is tracking now. If you go out with your phone you receive lots of notifications that inform you that the position is changed and a new CLRegion is created. Infinitely.

In a few days, you’ll receive a new system popup that informs you that the app wants to use your GPS location when is closed. Remember to select “Always Allow“.

If you add some API and send data to a remote DB you can analyze the user locations and make a map with polylines.

Enjoy tracking.

Alberto Pasca