Import GeoMonitor code

This commit is contained in:
Adrian Schoenig 2022-05-23 17:12:01 +10:00
parent aafb17b10a
commit 41c609b8cb
4 changed files with 537 additions and 26 deletions

View File

@ -4,25 +4,28 @@
import PackageDescription
let package = Package(
name: "GeoMonitor",
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "GeoMonitor",
targets: ["GeoMonitor"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "GeoMonitor",
dependencies: []),
.testTarget(
name: "GeoMonitorTests",
dependencies: ["GeoMonitor"]),
]
name: "GeoMonitor",
platforms: [
.iOS(.v14)
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "GeoMonitor",
targets: ["GeoMonitor"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "GeoMonitor",
dependencies: []),
.testTarget(
name: "GeoMonitorTests",
dependencies: ["GeoMonitor"]),
]
)

View File

@ -1,3 +1,55 @@
# GeoMonitor
A description of this package.
A battery-efficient and privacy-preserving toolkit for monitoring the user's
location, triggering callbacks when the user starts moving and monitoring
whether the user approaches specified regions.
Relies on a mixture of techniques, such as:
- Region-monitoring for detecting when the user leaves their current location
- Region-monitoring for detecting when the user approaches pre-defined locations
- Visit-monitoring for detecting when the user has arrived somewhere
## Setup
TODO:
## Usage
TODO:
- [ ] Optional: Set maximum number of regions for GeoMonitor to use. If this
is not specified, it'll use the maximum of 20. Set this if you're
monitoring regions yourself.
```swift
self.monitor = GeoMonitor {
// Fetch the latest regions; also called when entering one.
// Make sure `region.identifier` is stable.
let regions = await ...
return circles = regions.map { CLCircularRegion(...) }
} onEvent: { event, currentLocation in
switch event {
case .departed(visit):
// Called when a previously-visited location was left
case .entered(region):
// Called when entering a defined region.
let notification = MyNotification(for: region)
notification.fire()
case .arrived(visit):
// Called when a visit was registered
}
}
monitor.maxRegionsToMonitor = 18
monitor.enableVisitMonitoring = true
monitor.start()
```
## Considerations
TODO:
Regular iOS restrictions apply, such as:
> [...] When Background App Refresh is disabled, either for your app or for all apps, the user must explicitly launch your app to resume the delivery of all location-related events.

View File

@ -1,6 +1,432 @@
public struct GeoMonitor {
public private(set) var text = "Hello, World!"
public init() {
}
import Foundation
import CoreLocation
import MapKit
public protocol GeoMonitorDataSource {
func fetchRegions(trigger: GeoMonitor.FetchTrigger) async -> [CLCircularRegion]
}
public class GeoMonitor: NSObject, ObservableObject {
enum Constants {
#if DEBUG
static var currentLocationRegionMaximumRadius: CLLocationDistance = 400
static var currentLocationRegionRadiusDelta: CLLocationDistance = 350
#else
static var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500
static var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000
#endif
}
public enum FetchTrigger: String {
case initial
case visitMonitoring
case regionMonitoring
case departedCurrentArea
}
public enum LocationFetchError: Error {
/// Happens if you stop monitoring before a location could be found
case noLocationFetchedInTime
/// Happens if no accurate fix could be found, best location attached
case locationInaccurate(CLLocation)
}
#if DEBUG
public enum DebugKind {
case updatedCurrentLocationRegion
case enteredRegion
case visitMonitoring
case stateChange
case failure
}
#endif
public enum Event {
#if DEBUG
case debug(String, DebugKind)
#endif
case entered(CLCircularRegion, CLLocation?)
}
private let fetchSource: GeoMonitorDataSource
let eventHandler: (Event) -> Void
private let locationManager: CLLocationManager
public var maxRegionsToMonitor = 20
public convenience init(fetch: @escaping (GeoMonitor.FetchTrigger) async -> [CLCircularRegion], onEvent: @escaping (Event) -> Void) {
self.init(dataSource: SimpleDataSource(handler: fetch), onEvent: onEvent)
}
public init(dataSource: GeoMonitorDataSource, onEvent: @escaping (Event) -> Void) {
fetchSource = dataSource
eventHandler = onEvent
locationManager = .init()
hasAccess = false
super.init()
locationManager.delegate = self
#if !DEBUG
locationManager.activityType = .automotiveNavigation
#endif
}
// MARK: - Access
@Published public var hasAccess: Bool
private var askHandler: (Bool) -> Void = { _ in }
public var canAsk: Bool {
switch locationManager.authorizationStatus {
case .notDetermined:
return true
case .authorizedAlways, .authorizedWhenInUse, .denied, .restricted:
return false
@unknown default:
return false
}
}
private func updateAccess() {
switch locationManager.authorizationStatus {
case .authorizedAlways:
hasAccess = true
enableInBackground = true
case .authorizedWhenInUse:
hasAccess = true
enableInBackground = false
case .denied, .notDetermined, .restricted:
hasAccess = false
enableInBackground = false
@unknown default:
hasAccess = false
enableInBackground = false
}
}
public func ask(forBackground: Bool = false, _ handler: @escaping (Bool) -> Void = { _ in }) {
self.askHandler = handler
if forBackground {
locationManager.requestAlwaysAuthorization()
} else {
locationManager.requestWhenInUseAuthorization()
}
}
// MARK: - Location tracking
@Published public var currentLocation: CLLocation?
@Published public var isTracking: Bool = false {
didSet {
if isTracking {
locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
locationManager.distanceFilter = 250
locationManager.startUpdatingLocation()
} else {
locationManager.stopUpdatingLocation()
}
}
}
// MARK: - Current region monitoring
@Published public var enableInBackground: Bool = false {
didSet {
guard enableInBackground != oldValue else { return }
if enableInBackground, (locationManager.authorizationStatus == .notDetermined || locationManager.authorizationStatus == .authorizedWhenInUse) {
ask(forBackground: true)
} else {
updateAccess()
}
}
}
private var regionsToMonitor: [CLCircularRegion] = []
private var isMonitoring: Bool = false
private var withNextLocation: ((Result<CLLocation, Error>) -> Void)? = nil
private var currentLocationRegion: CLRegion? = nil
public var enableVisitMonitoring = true {
didSet {
if isMonitoring {
if enableVisitMonitoring {
locationManager.startMonitoringVisits()
} else {
locationManager.stopMonitoringVisits()
}
}
}
}
public func startMonitoring() {
guard !isMonitoring else { return }
isMonitoring = true
locationManager.allowsBackgroundLocationUpdates = true
locationManager.pausesLocationUpdatesAutomatically = enableInBackground // we can do that, as it implies "always on" permissions
Task {
// It's okay for this to fail, but best to enable visit monitoring as
// a backup.
try? await monitorCurrentArea()
}
if enableVisitMonitoring {
locationManager.startMonitoringVisits()
}
}
public func stopMonitoring() {
guard isMonitoring else { return }
isMonitoring = false
locationManager.allowsBackgroundLocationUpdates = false
locationManager.pausesLocationUpdatesAutomatically = true
stopMonitoringCurrentArea()
locationManager.stopMonitoringVisits()
}
private func fetchCurrentLocation() async throws -> CLLocation {
if let existing = withNextLocation {
assertionFailure()
existing(.failure(LocationFetchError.noLocationFetchedInTime))
withNextLocation = nil
}
let originalAccuracy = locationManager.desiredAccuracy
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.requestLocation()
return try await withCheckedThrowingContinuation { continuation in
withNextLocation = { [unowned self] result in
self.locationManager.desiredAccuracy = originalAccuracy
continuation.resume(with: result)
}
}
}
}
// MARK: - Trigger on move
extension GeoMonitor {
func runUpdateCycle(trigger: FetchTrigger) async {
// Re-monitor current area, so that it updates the data again
// and also fetch current location at same time, to prioritise monitoring
// when we leave it.
let location = try? await monitorCurrentArea()
// Ask to fetch data and wait for this to complete
let regions = await fetchSource.fetchRegions(trigger: trigger)
self.monitor(regions, location: location)
}
func monitorCurrentArea() async throws -> CLLocation {
let location = try await fetchCurrentLocation()
// Monitor a radius around it, using a single fixed "my location" circle
if let previous = currentLocationRegion as? CLCircularRegion, previous.contains(location.coordinate) {
return location
}
// Monitor new region
let region = CLCircularRegion(
center: location.coordinate,
radius:
// "In iOS 6, regions with a radius between 1 and 400 meters work better on iPhone 4S or later devices. "
min(Constants.currentLocationRegionMaximumRadius,
// "This property defines the largest boundary distance allowed from a regions center point. Attempting to monitor a region with a distance larger than this value causes the location manager to send a CLError.Code.regionMonitoringFailure error to the delegate."
min(self.locationManager.maximumRegionMonitoringDistance,
location.horizontalAccuracy + Constants.currentLocationRegionRadiusDelta
)
),
identifier: "current-location"
)
self.currentLocationRegion = region
self.locationManager.startMonitoring(for: region)
#if DEBUG
eventHandler(.debug("GeoMonitor is monitoring \(MKDistanceFormatter().string(fromDistance: region.radius))...", .updatedCurrentLocationRegion))
#endif
// ... continues in `didExitRegion`...
return location
}
func stopMonitoringCurrentArea() {
withNextLocation?(.failure(LocationFetchError.noLocationFetchedInTime))
withNextLocation = nil
currentLocationRegion = nil
}
}
// MARK: - Alert monitoring logic
extension GeoMonitor {
func monitor(_ regions: [CLCircularRegion], location: CLLocation?) {
regionsToMonitor = regions
let limit = maxRegionsToMonitor - 1 // we also monitor the current location
if regions.count <= limit {
regions.forEach(locationManager.startMonitoring(for:))
} else {
if let currentLocation = location {
let nearest = regions.sorted { lhs, rhs in
let leftDistance = currentLocation.distance(from: .init(latitude: lhs.center.latitude, longitude: lhs.center.longitude))
let rightDistance = currentLocation.distance(from: .init(latitude: rhs.center.latitude, longitude: rhs.center.longitude))
return leftDistance < rightDistance
}.prefix(limit)
nearest.forEach(locationManager.startMonitoring(for:))
} else {
regions.prefix(limit).forEach(locationManager.startMonitoring(for:))
}
}
}
}
// MARK: - CLLocationManagerDelegate
extension GeoMonitor: CLLocationManagerDelegate {
public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
guard region.identifier != currentLocationRegion?.identifier else {
return // Ignore re-entering current region; we only care about exiting this
}
#if DEBUG
eventHandler(.debug("GeoMonitor entered -> \(region)", .enteredRegion))
#endif
Task {
// Make sure this is still a *current* region => update data
await runUpdateCycle(trigger: .regionMonitoring)
// Now we can check
guard let match = regionsToMonitor.first(where: { $0.identifier == region.identifier }) else {
return // Has since disappeared
}
let location = try? await fetchCurrentLocation()
eventHandler(.entered(match, location))
}
}
public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
guard currentLocationRegion?.identifier == region.identifier else {
return // Ignore exiting a monitored region; we only care about entering these.
}
Task {
// 3. When leaving the current location, fetch...
await runUpdateCycle(trigger: .departedCurrentArea)
}
}
public func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
#if DEBUG
if visit.departureDate == .distantFuture {
eventHandler(.debug("GeoMonitor visit arrival -> \(visit)", .visitMonitoring))
} else {
let duration = DateComponentsFormatter().string(from: visit.arrivalDate, to: visit.departureDate) ?? "unknown duration"
eventHandler(.debug("GeoMonitor visit departure after \(duration) -> \(visit)", .visitMonitoring))
}
#endif
Task {
// TODO: We could detect if it's an arrival at a new location, by checking `visit.departureTime == .distanceFuture`
await runUpdateCycle(trigger: .visitMonitoring)
}
}
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
#if DEBUG
print("GeoMonitor updated locations -> \(locations)")
#endif
guard let latest = locations.last else { return assertionFailure() }
guard let latestAccurate = locations
.filter({ $0.horizontalAccuracy <= manager.desiredAccuracy })
.last
else {
withNextLocation?(.failure(LocationFetchError.locationInaccurate(latest)))
withNextLocation = nil
return
}
self.currentLocation = latestAccurate
withNextLocation?(.success(latestAccurate))
withNextLocation = nil
}
public func locationManagerDidPauseLocationUpdates(_ manager: CLLocationManager) {
#if DEBUG
eventHandler(.debug("GeoMonitor paused updates -> \(manager == locationManager)", .stateChange))
#endif
}
public func locationManagerDidResumeLocationUpdates(_ manager: CLLocationManager) {
#if DEBUG
eventHandler(.debug("GeoMonitor resumed updates -> \(manager == locationManager)", .stateChange))
#endif
}
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
#if DEBUG
eventHandler(.debug("GeoMonitor failed -> \(error)", .failure))
print("GeoMonitor's location manager failed: \(error)")
#endif
withNextLocation?(.failure(error))
withNextLocation = nil
}
public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updateAccess()
askHandler(hasAccess)
askHandler = { _ in }
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
if isMonitoring {
startMonitoring()
}
case .denied, .notDetermined, .restricted:
return
@unknown default:
return
}
}
}
// MARK: - Helpers
private struct SimpleDataSource: GeoMonitorDataSource {
let handler: (GeoMonitor.FetchTrigger) async -> [CLCircularRegion]
func fetchRegions(trigger: GeoMonitor.FetchTrigger) async -> [CLCircularRegion] {
await handler(trigger)
}
}

View File

@ -0,0 +1,30 @@
//
// PrioritizedRegion.swift
//
//
// Created by Adrian Schönig on 3/5/2022.
//
import Foundation
import CoreLocation
public class PrioritizedRegion: CLCircularRegion {
public let priority: Int
public init(center: CLLocationCoordinate2D, radius: CLLocationDistance, identifier: String, priority: Int) {
self.priority = priority
super.init(center: center, radius: radius, identifier: identifier)
}
public required init?(coder: NSCoder) {
priority = coder.decodeInteger(forKey: "priority")
super.init(coder: coder)
}
public override func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(priority, forKey: "priority")
}
}