# CoreLocation Patterns

Modern CoreLocation with Swift concurrency, iOS 17 and later unless a heading
says otherwise. Import `CoreLocation` in each file that uses these types.

## Contents

- [CLLocationUpdate.liveUpdates()](#cllocationupdateliveupdates)
- [Service Sessions (iOS 18+)](#service-sessions-ios-18)
- [Geofences with CLMonitor (iOS 17+)](#geofences-with-clmonitor-ios-17)
- [CLBackgroundActivitySession](#clbackgroundactivitysession)
- [Coarse Updates from Significant Changes](#coarse-updates-from-significant-changes)
- [Visits](#visits)
- [Moving Off CLCircularRegion](#moving-off-clcircularregion)
- [Choosing Accuracy](#choosing-accuracy)
- [Simulating Position](#simulating-position)
- [Info.plist Privacy Keys](#infoplist-privacy-keys)
- [Pitfalls](#pitfalls)
- [References](#references)

## CLLocationUpdate.liveUpdates()

### Basic Usage

```swift
func logPositions() async throws {
    for try await reading in CLLocationUpdate.liveUpdates() {
        guard let here = reading.location else { continue }
        let lat = here.coordinate.latitude
        let lon = here.coordinate.longitude
        let errorMetres = here.horizontalAccuracy
        _ = (lat, lon, errorMetres)
    }
}
```

### Full Implementation with Filtering

Filtering keeps the UI from redrawing on noise and saves battery. Each rule
below drops a sample that should never reach the screen.

```swift
import CoreLocation
import Observation

@MainActor
@Observable
final class HikeLocator {
    private(set) var position: CLLocation?
    private(set) var isRunning = false
    private var feed: Task<Void, Never>?
    private var lastPublished: CLLocation?

    private let minimumMove: CLLocationDistance = 10
    private let worstAccuracy: CLLocationAccuracy = 100
    private let oldestAge: TimeInterval = 15
    private let fastestSpeed: CLLocationSpeed = 80

    func start() {
        guard !isRunning else { return }
        isRunning = true
        feed = Task {
            do {
                for try await reading in CLLocationUpdate.liveUpdates(.default) {
                    if Task.isCancelled { break }
                    guard let fix = reading.location, accept(fix) else { continue }
                    lastPublished = fix
                    position = fix
                }
            } catch is CancellationError {
            } catch {
                position = nil
            }
            isRunning = false
        }
    }

    func stop() {
        feed?.cancel()
        feed = nil
        isRunning = false
    }

    private func accept(_ fix: CLLocation) -> Bool {
        if fix.horizontalAccuracy < 0 || fix.horizontalAccuracy >= worstAccuracy { return false }
        if abs(fix.timestamp.timeIntervalSinceNow) >= oldestAge { return false }
        if fix.speed >= 0 && fix.speed > fastestSpeed { return false }
        if fix.course >= 0 && fix.courseAccuracy < 0 { return false }
        if let lastPublished, fix.distance(from: lastPublished) < minimumMove { return false }
        return true
    }
}
```

The rules: accuracy must be valid (not negative) and under 100 m; the sample
must be younger than 15 s; a speed above 80 m/s is a glitch; a course without
a valid course accuracy is ignored; moves under 10 m from the last published
fix are not worth a redraw. `start()` refuses to run twice.

### LiveConfiguration Options

`CLLocationUpdate.liveUpdates(_:)` takes a `LiveConfiguration`:

| Value | Tuned for |
|-------|-----------|
| `.default` | balanced power and accuracy |
| `.automotiveNavigation` | turn-by-turn driving, where precision and update rate matter most |
| `.otherNavigation` | navigation that is not by car |
| `.fitness` | workouts |
| `.airborne` | drones and aviation |

### Diagnostics and Fallbacks (iOS 18+)

Own every `liveUpdates()` stream in a stored `Task` and cancel it when the
map, route or monitoring flow ends. Treat a diagnostic or a bad sample as a
change of state: update the UI, fall back to cached or typed input, or pause
background work. A loop that silently waits forever is the failure mode.

```swift
@available(iOS 18, *)
func watch(onDenied: @escaping @MainActor () -> Void) async throws {
    for try await reading in CLLocationUpdate.liveUpdates() {
        if reading.authorizationDenied {
            await onDenied()
            break
        }
        if reading.authorizationDeniedGlobally { break }
        if reading.insufficientlyInUse { continue }
        if reading.locationUnavailable { continue }
        if reading.stationary { continue }
        if let fix = reading.location { _ = fix }
    }
}
```

| Diagnostic (iOS 18+) | Meaning | Response |
|----------------------|---------|----------|
| `authorizationDenied` | the user said no | stop location work, show a Settings path |
| `authorizationDeniedGlobally` | Location Services are off for the device | explain the system switch |
| `authorizationRestricted` | parental or MDM restriction | explain; no Settings fix for the user |
| `insufficientlyInUse` | the app is not in a state that allows updates | pause; resume when the user returns to the feature |
| `locationUnavailable` | temporarily cannot locate | keep iterating; let the user type a place or use the last known or visible area |
| `accuracyLimited` | only approximate location | search a wider radius and size geofences generously; ask for precise location only when the user starts something that needs it |
| `stationary` | device stopped moving; updates pause | nothing to do; they resume on movement |

Availability: all of these are iOS 18 properties. On iOS 17 the only related
property is `isStationary`, which iOS 18 deprecates in favour of
`stationary`; detect unavailability on iOS 17 by `location == nil` on the update.

Before publishing any fix, check that horizontal accuracy is valid, the
timestamp is recent and the movement is plausible. `speed` and `course` are
negative when unknown; read them only when non-negative.

## Service Sessions (iOS 18+)

`CLServiceSession` is iOS 18 and later, and unavailable on macOS.

### Setup and Lifecycle

The session announces what a feature needs. Keep a strong reference for as
long as the feature runs; dropping it tells the system you are done.

```swift
@available(iOS 18, *)
@MainActor
@Observable
final class NearbyFeature {
    private var session: CLServiceSession?
    private var feed: Task<Void, Never>?
    private(set) var here: CLLocation?

    func activate() {
        session = CLServiceSession(authorization: .whenInUse)
        feed = Task {
            do {
                for try await reading in CLLocationUpdate.liveUpdates() {
                    if let fix = reading.location { here = fix }
                }
            } catch is CancellationError {
            } catch {
                here = nil
            }
        }
    }

    func deactivate() {
        feed?.cancel()
        feed = nil
        session = nil
    }
}
```

On an unexpected error, move to a degraded state and offer retry rather than
leaving the screen spinning.

### Full Accuracy Request

When the user granted only approximate location, ask for precision for one
feature:

```swift
@available(iOS 18, *)
func preciseSession() -> CLServiceSession {
    CLServiceSession(authorization: .whenInUse, fullAccuracyPurposeKey: "TrailNavigation")
}
```

The key must exist in Info.plist under
`NSLocationTemporaryUsageDescriptionDictionary`, mapped to a sentence the user
will read.

### Always Authorization

`CLServiceSession(authorization: .always)` is for one case: the system must
relaunch the app in the background after termination, for significant
location changes. It requires `NSLocationAlwaysAndWhenInUseUsageDescription`.

### Implicit vs. Explicit Sessions

If the app has no session, iOS 18 makes one on its behalf as soon as
`liveUpdates()` or `CLMonitor` starts. Create one explicitly when:

- the feature needs `.always`;
- the feature needs full accuracy through `fullAccuracyPurposeKey`;
- Info.plist sets `NSLocationRequireExplicitServiceSession`, which turns
  implicit sessions off.

## Geofences with CLMonitor (iOS 17+)

`CLMonitor` is iOS 17 and macOS 14 and later; it is not available on watchOS,
tvOS or visionOS.

### Basic Setup

`CLMonitor` is an `actor`, so every call is `await`ed. Create it with a name,
add conditions, and read one `events` sequence.

```swift
import CoreLocation

struct Geofence: Sendable {
    let id: String
    let center: CLLocationCoordinate2D
    let radius: CLLocationDistance
}

@available(iOS 17, *)
actor ParkGates {
    private var monitor: CLMonitor?
    private var listener: Task<Void, any Error>?

    func start(_ fences: [Geofence], onChange: @escaping @Sendable (String, Bool) -> Void) async {
        let monitor = await CLMonitor("park-gates")
        self.monitor = monitor
        for fence in fences {
            let area = CLMonitor.CircularGeographicCondition(center: fence.center, radius: fence.radius)
            await monitor.add(area, identifier: fence.id)
        }
        listener = Task {
            for try await change in await monitor.events {
                switch change.state {
                case .satisfied: onChange(change.identifier, true)
                case .unsatisfied: onChange(change.identifier, false)
                default: break
                }
            }
        }
    }

    func stop() async {
        listener?.cancel()
        listener = nil
        if let monitor {
            for id in await monitor.identifiers {
                await monitor.remove(id)
            }
        }
        monitor = nil
    }
}
```

`.satisfied` means the device is inside (entry), `.unsatisfied` outside
(exit); `.unknown` and anything else are ignored. `event.identifier` names the
condition.

### Critical CLMonitor Rules

- At most 20 conditions per app, counted across all monitors. Extra ones
  report the `unmonitored` state.
- Never recreate a monitor quickly. Creating one with the name of a live
  monitor crashes the app. Keep one and call `add` and `remove`.
- Subscribe to `events` once per monitor. Cancelling and subscribing again
  gives a subscription that ends immediately; keep one long-lived listener.
- Change conditions by diffing (add the new, remove the gone), not by
  removing everything and adding it back.
- Where possible, target iOS 18 and pair `CLMonitor` with a `CLServiceSession`
  so authorization is handled reliably.

### Adding Conditions with Initial State

`add(_:identifier:assuming:)` supplies the state you believe is true, which
avoids a spurious first event. Pass `.unsatisfied` when the device is
believed outside, `.satisfied` when inside.

### Updating Conditions Dynamically

```swift
@available(iOS 17, *)
func sync(_ monitor: CLMonitor, to fences: [Geofence]) async {
    let current = Set(await monitor.identifiers)
    let wanted = Set(fences.map(\.id))
    for gone in current.subtracting(wanted) {
        await monitor.remove(gone)
    }
    for fence in fences where !current.contains(fence.id) {
        let area = CLMonitor.CircularGeographicCondition(center: fence.center, radius: fence.radius)
        await monitor.add(area, identifier: fence.id, assuming: .unsatisfied)
    }
}
```

### Checking Last Known State

```swift
@available(iOS 17, *)
func lastSeen(_ monitor: CLMonitor, fence id: String) async -> (CLMonitor.Event.State, Date)? {
    let saved = await monitor.record(for: id)
    guard let last = saved?.lastEvent else { return nil }
    return (last.state, last.date)
}
```

## CLBackgroundActivitySession

Available from iOS 17; not on macOS or tvOS. It lets an app with when-in-use
authorization keep receiving location in the background. It needs the
Location updates background mode, and while it is held the system shows the
blue location indicator.

```swift
@available(iOS 17, *)
actor WorkoutRecorder {
    private var background: CLBackgroundActivitySession?
    private var service: AnyObject?
    private var feed: Task<Void, Never>?
    private var trail: [CLLocation] = []

    func begin() {
        if #available(iOS 18, *) {
            service = CLServiceSession(authorization: .whenInUse)
        }
        background = CLBackgroundActivitySession()
        feed = Task {
            do {
                for try await reading in CLLocationUpdate.liveUpdates(.fitness) {
                    if let fix = reading.location { record(fix) }
                }
            } catch {}
        }
    }

    private func record(_ fix: CLLocation) {
        trail.append(fix)
    }

    func finish() {
        feed?.cancel()
        feed = nil
        background?.invalidate()
        background = nil
        service = nil
    }
}
```

### Background Requirements Summary

Background location works only when all of these hold:

1. Background Modes > Location updates is enabled.
2. Info.plist has `NSLocationWhenInUseUsageDescription`.
3. The user granted `.whenInUse` or `.always`.
4. The app holds a `CLBackgroundActivitySession`, or runs a Live Activity
   (see `live-activities`).
5. A `CLLocationUpdate` or `CLMonitor` subscription is active.

`.always` is not needed for this. What `.always` adds is relaunch: after the
app has been terminated, a significant move can bring it back. With
`.whenInUse` plus a background session, the app must still be running,
in the foreground or suspended.

## Coarse Updates from Significant Changes

Coarse updates, roughly every 500 m, driven by cell tower changes and cheap on
battery. There is no `CLLocationUpdate` equivalent, so this stays on
`CLLocationManager`, which is legacy but valid:

```swift
let coarseManager = CLLocationManager()
coarseManager.startMonitoringSignificantLocationChanges()
```

The delegate hears about moves of 500 m or more, typically 1 to 5 minutes
apart.

## Visits

Arrivals and departures at places, for journaling, check-ins or context-aware
features.

```swift
final class VisitLog: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()

    func start() {
        manager.delegate = self
        manager.startMonitoringVisits()
    }

    func locationManager(_ manager: CLLocationManager, didVisit stop: CLVisit) {
        let stillThere = stop.departureDate == .distantFuture
        _ = (stop.coordinate, stop.arrivalDate, stillThere)
    }
}
```

A `departureDate` of `.distantFuture` means the user has not left yet.

## Moving Off CLCircularRegion

### Older Delegate Approach

```swift
let gate = CLCircularRegion(center: gateCenter, radius: 200, identifier: "north-gate")
gate.notifyOnEntry = true
gate.notifyOnExit = true
legacyManager.startMonitoring(for: gate)
```

### Modern Approach (iOS 17+)

```swift
@available(iOS 17, *)
func watchGate(_ center: CLLocationCoordinate2D) async throws {
    let monitor = await CLMonitor("gates")
    await monitor.add(CLMonitor.CircularGeographicCondition(center: center, radius: 200),
                      identifier: "north-gate")
    for try await change in await monitor.events where change.identifier == "north-gate" {
        switch change.state {
        case .satisfied: break
        case .unsatisfied: break
        default: break
        }
    }
}
```

| | `CLCircularRegion` | `CLMonitor` |
|-|--------------------|-------------|
| Shape of the API | callbacks on a delegate | an async sequence of events |
| Region limit | 20 for the whole app | same 20, shared |
| Entry and exit | two booleans | one state enum (satisfied, unsatisfied) |
| Concurrency | `@objc` delegate | actor |
| Minimum iOS | 7 | 17 |

## Choosing Accuracy

### Accuracy Levels for CLLocationManager

Set with `manager.desiredAccuracy`:

| Constant | Roughly | Source and cost |
|----------|---------|-----------------|
| `kCLLocationAccuracyBest` | 5 m | GPS, most power |
| `kCLLocationAccuracyNearestTenMeters` | 10 m | |
| `kCLLocationAccuracyHundredMeters` | 100 m | Wi-Fi |
| `kCLLocationAccuracyKilometer` | 1 km | cell towers |
| `kCLLocationAccuracyThreeKilometers` | 3 km | least power |
| `kCLLocationAccuracyReduced` | 5 km | approximate, privacy-preserving |

### Activity Type

`manager.activityType` guides power management:

| Value | Use |
|-------|-----|
| `.other` | default |
| `.automotiveNavigation` | highway speeds, high accuracy |
| `.fitness` | walking and running |
| `.otherNavigation` | boats, trains |
| `.airborne` | drones and aircraft (iOS 12+) |

### CLLocationUpdate has no filtering

`liveUpdates()` has neither `desiredAccuracy` nor `distanceFilter`. Pick a
`LiveConfiguration` and filter the stream yourself, for example keep only
fixes with `horizontalAccuracy >= 0 && horizontalAccuracy < 50`.

## Simulating Position

- One fixed spot: Xcode Debug > Simulate Location, then a city or a custom
  coordinate.
- A moving path: add a `.gpx` file to the project and pick it under Edit
  Scheme > Run > Options > Default Location.

```xml
<?xml version="1.0"?>
<gpx creator="Xcode" version="1.1">
    <wpt lat="47.6062" lon="-122.3321">
        <name>Start</name>
        <time>2026-01-01T09:00:00Z</time>
    </wpt>
    <wpt lat="47.6097" lon="-122.3331">
        <name>Market</name>
        <time>2026-01-01T09:05:00Z</time>
    </wpt>
</gpx>
```

The simulator moves between waypoints at the pace their timestamps imply and
starts over after the last one.

### Programmatic Simulation in Tests

Unit tests need a position source they control. Hide the stream behind a
protocol and inject a fake:

```swift
protocol PositionSource: Sendable {
    func positions() -> AsyncStream<CLLocation>
}

struct DevicePositionSource: PositionSource {
    func positions() -> AsyncStream<CLLocation> {
        AsyncStream { continuation in
            let pump = Task {
                do {
                    for try await reading in CLLocationUpdate.liveUpdates() {
                        if let fix = reading.location { continuation.yield(fix) }
                    }
                } catch {}
                continuation.finish()
            }
            continuation.onTermination = { _ in pump.cancel() }
        }
    }
}

struct ScriptedPositionSource: PositionSource {
    let script: [CLLocation]

    func positions() -> AsyncStream<CLLocation> {
        AsyncStream { continuation in
            for fix in script { continuation.yield(fix) }
            continuation.finish()
        }
    }
}
```

The same seam works for code still on `CLLocationManager`: wrap the delegate
behind the protocol and feed the scripted source in XCTest or Swift Testing.

## Info.plist Privacy Keys

### Required Keys

| Key | When |
|-----|------|
| `NSLocationWhenInUseUsageDescription` | every app that reads location at all |
| `NSLocationAlwaysAndWhenInUseUsageDescription` | only when requesting `.always` |

### Optional Keys

| Key | Effect |
|-----|--------|
| `NSLocationTemporaryUsageDescriptionDictionary` | purpose strings for per-feature full accuracy |
| `NSLocationRequireExplicitServiceSession` | disables implicit `CLServiceSession` (iOS 18+) |
| `NSLocationDefaultAccuracyReduced` | the app starts with approximate location |
| `UIBackgroundModes` containing `location` | background location updates |

### Usage Description Best Practices

Say what the user gets. "Shows trailheads within a short drive of you" is a
reason; "This app uses your location." is not, and App Review rejects vague
purpose strings.

## Pitfalls

### CLMonitor crash on rapid recreation

A second `CLMonitor` created with a name that is still in use raises
`NSInternalInconsistencyException`. Reuse the monitor. Make a new one only
after full teardown (conditions removed, reference released) and never in the
same run loop turn.

### Approximate location arrives rarely

Approximate location can arrive rarely. On iOS 18 check `accuracyLimited` and
`locationUnavailable`; on iOS 17 treat `location == nil` on the update as a state
change. Either way, give a degraded experience instead of a spinner or a
timeout.

### Forgetting to hold CLBackgroundActivitySession

`_ = CLBackgroundActivitySession()` deallocates at once and the session ends
with it. Store it in a property.

### Not checking horizontalAccuracy

A `horizontalAccuracy` of -1 marks the coordinate invalid. Check
`horizontalAccuracy >= 0` before using any fix.

## References

- [CLLocationUpdate](https://developer.apple.com/documentation/corelocation/cllocationupdate)
- [CLServiceSession](https://developer.apple.com/documentation/corelocation/clservicesession)
- [CLMonitor](https://developer.apple.com/documentation/corelocation/clmonitor)
- [CLBackgroundActivitySession](https://developer.apple.com/documentation/corelocation/clbackgroundactivitysession)
- [Location authorization](https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services)
- [Background location updates](https://developer.apple.com/documentation/corelocation/handling-location-updates-in-the-background)
