From 5cc645651a7b7a476a654f6f0105d5ccca8d4469 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 18 Feb 2026 16:38:02 +1100 Subject: [PATCH 1/7] Add customisable `GeoMonitor.Config` And bump requirements to iOS 16+ and Swift 6.1+ --- Package.swift | 4 +- Sources/GeoMonitor/GeoMonitor.swift | 60 ++++++++++++++--------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Package.swift b/Package.swift index 36b912c..59805aa 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.5 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "GeoMonitor", platforms: [ - .iOS(.v13) + .iOS(.v16) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Sources/GeoMonitor/GeoMonitor.swift b/Sources/GeoMonitor/GeoMonitor.swift index b4edf09..284f1eb 100644 --- a/Sources/GeoMonitor/GeoMonitor.swift +++ b/Sources/GeoMonitor/GeoMonitor.swift @@ -3,8 +3,7 @@ import Foundation import CoreLocation import MapKit -@available(iOS 14.0, *) -public protocol GeoMonitorDataSource { +@MainActor public protocol GeoMonitorDataSource { func fetchRegions(trigger: GeoMonitor.FetchTrigger) async -> [CLCircularRegion] } @@ -17,20 +16,21 @@ public protocol GeoMonitorDataSource { /// alerted, when they get to them (e.g., traffic incidents); where monitoring can be long-term. /// - Monitoring a set of regions where the user wants to be alerted as they approach them, but /// monitoring is limited for brief durations (e.g., "get off here" alerts for transit apps) -@available(iOS 14.0, *) @MainActor public class GeoMonitor: NSObject, ObservableObject { - enum Constants { - static var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500 - static var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000 - static var maximumDistanceToRegionCenter: CLLocationDistance = 10_000 - static var maximumDistanceForPriorityPruningCenter: CLLocationDistance = 5_000 - static var currentLocationFetchTimeOut: TimeInterval = 30 - static var currentLocationFetchRecency: TimeInterval = 10 - static var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 + public struct Config: Sendable { + public static let `default` = Config() + + public var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500 + public var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000 + public var maximumDistanceToRegionCenter: CLLocationDistance = 10_000 + public var maximumDistanceForPriorityPruningCenter: CLLocationDistance = 5_000 + public var currentLocationFetchTimeOut: TimeInterval = 30 + public var currentLocationFetchRecency: TimeInterval = 10 + public var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 } - public enum FetchTrigger: String { + public enum FetchTrigger: String, Sendable { case manual case initial case visitMonitoring @@ -84,14 +84,16 @@ public class GeoMonitor: NSObject, ObservableObject { /// Set to `true` if the `hasAccuracy` values should also check whether the user /// has provided access to the full accuracy/ public var needsFullAccuracy: Bool = false + + private let config: Config /// Instantiates new monitor /// - Parameters: /// - enabledKey: User defaults key to use store whether background tracking should be enabled /// - fetch: Handler that's called when the monitor decides it's a good time to update the regions to monitor. Should fetch and then return all regions to be monitored (even if they didn't change). /// - onEvent: Handler that's called when a relevant event is happening, including when one of the monitored regions is entered. - public convenience init(enabledKey: String? = nil, fetch: @escaping (GeoMonitor.FetchTrigger) async -> [CLCircularRegion], onEvent: @escaping (Event) -> Void) { - self.init(enabledKey: enabledKey, dataSource: SimpleDataSource(handler: fetch), onEvent: onEvent) + public convenience init(enabledKey: String? = nil, config: Config = .default, fetch: @escaping (GeoMonitor.FetchTrigger) async -> [CLCircularRegion], onEvent: @escaping (Event) -> Void) { + self.init(enabledKey: enabledKey, dataSource: SimpleDataSource(handler: fetch), config: config, onEvent: onEvent) } /// Instantiates new monitor @@ -99,10 +101,11 @@ public class GeoMonitor: NSObject, ObservableObject { /// - enabledKey: User defaults key to use store whether background tracking should be enabled /// - dataSource: Data source that provides regions. Will be maintained strongly. /// - onEvent: Handler that's called when a relevant event is happening, including when one of the monitored regions is entered. - public init(enabledKey: String? = nil, dataSource: GeoMonitorDataSource, onEvent: @escaping (Event) -> Void) { + public init(enabledKey: String? = nil, dataSource: GeoMonitorDataSource, config: Config = .default, onEvent: @escaping (Event) -> Void) { fetchSource = dataSource eventHandler = onEvent locationManager = .init() + self.config = config hasAccess = false self.enabledKey = enabledKey if let enabledKey = enabledKey { @@ -204,14 +207,14 @@ public class GeoMonitor: NSObject, ObservableObject { private var fetchTimer: Timer? - private func fetchCurrentLocation() async throws -> CLLocation { + public func fetchCurrentLocation() async throws -> CLLocation { guard hasAccess else { throw LocationFetchError.accessNotProvided } let desiredAccuracy = kCLLocationAccuracyHundredMeters if let currentLocation = currentLocation, - currentLocation.timestamp.timeIntervalSinceNow > Constants.currentLocationFetchRecency * -1, + currentLocation.timestamp.timeIntervalSinceNow > config.currentLocationFetchRecency * -1, currentLocation.horizontalAccuracy <= desiredAccuracy { // We have a current location and it's less than 10 seconds old. Just use it return currentLocation @@ -221,7 +224,7 @@ public class GeoMonitor: NSObject, ObservableObject { locationManager.desiredAccuracy = desiredAccuracy locationManager.requestLocation() - fetchTimer = .scheduledTimer(withTimeInterval: Constants.currentLocationFetchTimeOut, repeats: false) { [weak self] _ in + fetchTimer = .scheduledTimer(withTimeInterval: config.currentLocationFetchTimeOut, repeats: false) { [weak self] _ in Task { [weak self] in await self?.notify(.failure(LocationFetchError.noLocationFetchedInTime)) } @@ -346,7 +349,6 @@ public class GeoMonitor: NSObject, ObservableObject { // MARK: - Trigger on move -@available(iOS 14.0, *) extension GeoMonitor { @discardableResult @@ -379,11 +381,11 @@ extension GeoMonitor { 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, + min(config.currentLocationRegionMaximumRadius, // "This property defines the largest boundary distance allowed from a region’s 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 + location.horizontalAccuracy + config.currentLocationRegionRadiusDelta ) ), identifier: "current-location" @@ -406,7 +408,6 @@ extension GeoMonitor { // MARK: - Alert monitoring logic -@available(iOS 14.0, *) extension GeoMonitor { private func monitorDebounced(_ regions: [CLCircularRegion], location: CLLocation?, delay: TimeInterval? = nil) { @@ -440,7 +441,8 @@ extension GeoMonitor { let toMonitor = Self.determineRegionsToMonitor( regions: regions, location: currentLocation, - max: maxRegionsToMonitor - 1 // keep one for current location + max: maxRegionsToMonitor - 1, // keep one for current location + config: config ) // Stop monitoring regions that are no irrelevant @@ -464,7 +466,7 @@ extension GeoMonitor { } @MainActor - static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int) -> [CLCircularRegion] { + static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int, config: Config) -> [CLCircularRegion] { let processed: [(CLCircularRegion, distance: CLLocationDistance?, priority: Int?)] = regions.map { region in let distance = location.map { $0.distance(from: .init(latitude: region.center.latitude, longitude: region.center.longitude)) } @@ -474,7 +476,7 @@ extension GeoMonitor { // Then effectively monitor the nearest let nearby = processed.filter { _, distance, _ in - (distance ?? 0) < Constants.maximumDistanceToRegionCenter + (distance ?? 0) < config.maximumDistanceToRegionCenter } // The ones to monitor, optionally pruned by either priority or the nearest @@ -484,7 +486,7 @@ extension GeoMonitor { let prefix = nearby .sorted { lhs, rhs in - if let leftDistance = lhs.distance, let rightDistance = rhs.distance, leftDistance > Constants.maximumDistanceForPriorityPruningCenter || rightDistance > Constants.maximumDistanceForPriorityPruningCenter { + if let leftDistance = lhs.distance, let rightDistance = rhs.distance, leftDistance > config.maximumDistanceForPriorityPruningCenter || rightDistance > config.maximumDistanceForPriorityPruningCenter { return leftDistance < rightDistance } else if let leftPriority = lhs.priority, let rightPriority = rhs.priority, leftPriority != rightPriority { return leftPriority > rightPriority @@ -500,8 +502,7 @@ extension GeoMonitor { // MARK: - CLLocationManagerDelegate -@available(iOS 14.0, *) -extension GeoMonitor: CLLocationManagerDelegate { +extension GeoMonitor: @MainActor CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { dispatchPrecondition(condition: .onQueue(.main)) @@ -529,7 +530,7 @@ extension GeoMonitor: CLLocationManagerDelegate { do { let location = try await fetchCurrentLocation() - let minInterval = Constants.minIntervalBetweenEnteringSameRegion * -1 + let minInterval = config.minIntervalBetweenEnteringSameRegion * -1 if let lastReport = recentlyReportedRegionIdentifiers.first(where: { $0.0 == region.identifier }), lastReport.1.timeIntervalSinceNow >= minInterval { eventHandler(.status("GeoMonitor reported duplicate for \(region.identifier). Entered \(lastReport.1.timeIntervalSinceNow * -1) seconds ago.", .enteredRegion)) return // Already reported with `minIntervalBetweenEnteringSameRegion` @@ -643,7 +644,6 @@ extension GeoMonitor: CLLocationManagerDelegate { // MARK: - Helpers -@available(iOS 14.0, *) private struct SimpleDataSource: GeoMonitorDataSource { let handler: (GeoMonitor.FetchTrigger) async -> [CLCircularRegion] From 4924efcaa9395ec411f78e6cd8e385db96cbc237 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 18 Feb 2026 16:38:48 +1100 Subject: [PATCH 2/7] Back to latest Xcode --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 514cca5..339c21f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 26.0.1 # 26.1 is missing simulators on GHA as of November 2025 + xcode-version: latest - uses: actions/checkout@v4 - name: Build & Test run: set -o pipefail && xcodebuild test -scheme 'GeoMonitor' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' | xcbeautify From 0c7d8a237c28accf5e0bd88fc235d8ed816132c0 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 18 Feb 2026 16:48:47 +1100 Subject: [PATCH 3/7] Compile fix for test --- Tests/GeoMonitorTests/GeoMonitorTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/GeoMonitorTests/GeoMonitorTests.swift b/Tests/GeoMonitorTests/GeoMonitorTests.swift index 1bca6f1..7992d6d 100644 --- a/Tests/GeoMonitorTests/GeoMonitorTests.swift +++ b/Tests/GeoMonitorTests/GeoMonitorTests.swift @@ -3,7 +3,7 @@ import CoreLocation @testable import GeoMonitor -@available(iOS 14.0, *) +@MainActor final class GeoMonitorTests: XCTestCase { func testManyRegions() async throws { // This is an example of a functional test case. @@ -106,14 +106,14 @@ final class GeoMonitorTests: XCTestCase { let needle = CLLocation(latitude: -31.9586, longitude: 115.8681) - let withoutLocation = await GeoMonitor.determineRegionsToMonitor(regions: regions, location: nil, max: 19) + let withoutLocation = GeoMonitor.determineRegionsToMonitor(regions: regions, location: nil, max: 19, config: .default) XCTAssertEqual(withoutLocation.count, 19) XCTAssertFalse(withoutLocation.allSatisfy { needle.distance(from: .init(latitude: $0.center.latitude, longitude: $0.center.longitude)) <= 5_000 }) XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).min() ?? 0, 529) // highest priorities XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).max(), 900) XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.filter { $0.priority == 900 }.count, 8) // all top priorities included - let withLocation = await GeoMonitor.determineRegionsToMonitor(regions: regions, location: needle, max: 19) + let withLocation = GeoMonitor.determineRegionsToMonitor(regions: regions, location: needle, max: 19, config: .default) XCTAssertEqual(withLocation.count, 19) XCTAssertTrue(withLocation.allSatisfy { needle.distance(from: .init(latitude: $0.center.latitude, longitude: $0.center.longitude)) <= 5_000 }) XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).min() ?? 0, 349) // highest priorities From 7088cf93968e468c8815624b986e8a20c8c45e7a Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Thu, 19 Feb 2026 17:19:22 +1100 Subject: [PATCH 4/7] More detailed status message when updating monitored regions --- Sources/GeoMonitor/GeoMonitor.swift | 45 +++++++++++++++++++---------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/Sources/GeoMonitor/GeoMonitor.swift b/Sources/GeoMonitor/GeoMonitor.swift index 284f1eb..cafe578 100644 --- a/Sources/GeoMonitor/GeoMonitor.swift +++ b/Sources/GeoMonitor/GeoMonitor.swift @@ -438,15 +438,19 @@ extension GeoMonitor { let currentLocation = location ?? self.currentLocation - let toMonitor = Self.determineRegionsToMonitor( + let max = maxRegionsToMonitor - 1 // keep one for current location + let analyzed = Self.determineRegionsToMonitor( regions: regions, location: currentLocation, - max: maxRegionsToMonitor - 1, // keep one for current location + max: max, config: config ) + let toMonitor = analyzed + .filter(\.keep) + .prefix(max) // Stop monitoring regions that are no irrelevant - let toMonitorIDs = Set(toMonitor.map(\.identifier)) + let toMonitorIDs = Set(toMonitor.map(\.region.identifier)) var removedCount: Int = 0 for previous in locationManager.monitoredRegions { if !toMonitorIDs.contains(previous.identifier) && previous.identifier != "current-location" { @@ -458,44 +462,53 @@ extension GeoMonitor { // Start monitoring those we need to monitor let monitoredAlready = locationManager.monitoredRegions.map(\.identifier) let newRegion = toMonitor - .filter { !monitoredAlready.contains($0.identifier) } + .filter { !monitoredAlready.contains($0.region.identifier) } newRegion + .map(\.region) .forEach(locationManager.startMonitoring(for:)) - eventHandler(.status("Updating monitored regions. \(regions.count) candidates; monitoring \(toMonitor.count) regions; removed \(removedCount), kept \(monitoredAlready.count), added \(newRegion.count); now monitoring \(locationManager.monitoredRegions.count).", .updatingMonitoredRegions)) + let furthestMonitored = toMonitor.compactMap(\.distance).max() + eventHandler(.status("Updating monitored regions. \(regions.count) candidates; monitoring \(toMonitor.count) regions; removed \(removedCount), kept \(monitoredAlready.count), added \(newRegion.count); now monitoring \(locationManager.monitoredRegions.count). Furthest is \(furthestMonitored ?? -1).", .updatingMonitoredRegions)) + } + + struct AnalyzedRegion { + let region: CLCircularRegion + let distance: CLLocationDistance? + let priority: Int? + var keep: Bool } @MainActor - static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int, config: Config) -> [CLCircularRegion] { + static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int, config: Config) -> [AnalyzedRegion] { - let processed: [(CLCircularRegion, distance: CLLocationDistance?, priority: Int?)] = regions.map { region in + let processed: [AnalyzedRegion] = regions.map { region in let distance = location.map { $0.distance(from: .init(latitude: region.center.latitude, longitude: region.center.longitude)) } let priority = (region as? PrioritizedRegion)?.priority - return (region, distance: distance, priority: priority) + return .init(region: region, distance: distance, priority: priority, keep: true) } // Then effectively monitor the nearest - let nearby = processed.filter { _, distance, _ in - (distance ?? 0) < config.maximumDistanceToRegionCenter + let nearby = processed.map { analyzed in + var updated = analyzed + updated.keep = (analyzed.distance ?? 0) < config.maximumDistanceToRegionCenter + return updated } // The ones to monitor, optionally pruned by either priority or the nearest - guard nearby.count > max else { - return nearby.map(\.0) + guard nearby.count(where: \.keep) > max else { + return nearby } - let prefix = nearby + return nearby .sorted { lhs, rhs in if let leftDistance = lhs.distance, let rightDistance = rhs.distance, leftDistance > config.maximumDistanceForPriorityPruningCenter || rightDistance > config.maximumDistanceForPriorityPruningCenter { return leftDistance < rightDistance } else if let leftPriority = lhs.priority, let rightPriority = rhs.priority, leftPriority != rightPriority { return leftPriority > rightPriority } else { - return lhs.0.identifier < rhs.0.identifier + return lhs.region.identifier < rhs.region.identifier } } - .prefix(max) - return Array(prefix.map(\.0)) } } From 61723ffe0037d2355c622faf690b3bfafb565c35 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Tue, 3 Mar 2026 17:29:19 +1100 Subject: [PATCH 5/7] feat: add foreground location nudge API for fast-tracked region checks - add handleForegroundLocationUpdate(_:) to let foreground apps push live location into GeoMonitor - add configurable nudge thresholds in Config (accuracy, minimum distance, minimum interval) - reuse one duplicate-suppression path for both manual and region-enter events - refactor current-area monitoring to support nudge-driven updates without extra location fetches - add unit coverage for foreground nudge throttling (testShouldProcessForegroundNudge) --- Sources/GeoMonitor/GeoMonitor.swift | 136 +++++++++++++++----- Tests/GeoMonitorTests/GeoMonitorTests.swift | 42 ++++++ 2 files changed, 148 insertions(+), 30 deletions(-) diff --git a/Sources/GeoMonitor/GeoMonitor.swift b/Sources/GeoMonitor/GeoMonitor.swift index cafe578..5f89c10 100644 --- a/Sources/GeoMonitor/GeoMonitor.swift +++ b/Sources/GeoMonitor/GeoMonitor.swift @@ -21,13 +21,16 @@ public class GeoMonitor: NSObject, ObservableObject { public struct Config: Sendable { public static let `default` = Config() - public var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500 - public var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000 - public var maximumDistanceToRegionCenter: CLLocationDistance = 10_000 - public var maximumDistanceForPriorityPruningCenter: CLLocationDistance = 5_000 - public var currentLocationFetchTimeOut: TimeInterval = 30 - public var currentLocationFetchRecency: TimeInterval = 10 - public var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 + public var currentLocationRegionMaximumRadius: CLLocationDistance = 2_500 + public var currentLocationRegionRadiusDelta: CLLocationDistance = 2_000 + public var maximumDistanceToRegionCenter: CLLocationDistance = 10_000 + public var maximumDistanceForPriorityPruningCenter: CLLocationDistance = 5_000 + public var currentLocationFetchTimeOut: TimeInterval = 30 + public var currentLocationFetchRecency: TimeInterval = 10 + public var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 + public var foregroundNudgeMaximumHorizontalAccuracy: CLLocationAccuracy = 250 + public var foregroundNudgeMinimumDistance: CLLocationDistance = 400 + public var foregroundNudgeMinimumInterval: TimeInterval = 15 } public enum FetchTrigger: String, Sendable { @@ -79,6 +82,9 @@ public class GeoMonitor: NSObject, ObservableObject { private var monitorTask: Task? = nil + private var lastForegroundNudgeLocation: CLLocation? + private var lastForegroundNudgeAt: Date = .distantPast + public var maxRegionsToMonitor = 20 /// Set to `true` if the `hasAccuracy` values should also check whether the user @@ -329,20 +335,42 @@ public class GeoMonitor: NSObject, ObservableObject { let location = isMonitoring ? (try? await fetchCurrentLocation()) : nil monitorDebounced(regions, location: location) } + + /// Use a live foreground location to fast-track monitor updates and region entry checks, + /// without waiting for Core Location region-enter callbacks. + public func handleForegroundLocationUpdate(_ location: CLLocation) async { + dispatchPrecondition(condition: .onQueue(.main)) + + guard isMonitoring else { return } + guard location.horizontalAccuracy >= 0, location.horizontalAccuracy <= config.foregroundNudgeMaximumHorizontalAccuracy else { + return + } + + let now = Date() + guard Self.shouldProcessForegroundNudge( + lastLocation: lastForegroundNudgeLocation, + lastDate: lastForegroundNudgeAt, + location: location, + date: now, + minimumDistance: config.foregroundNudgeMinimumDistance, + minimumInterval: config.foregroundNudgeMinimumInterval + ) else { + return + } + + lastForegroundNudgeLocation = location + lastForegroundNudgeAt = now + + _ = monitorCurrentArea(using: location) + monitorDebounced(regionsToMonitor, location: location, delay: 0.5) + reportManualEventIfNeeded(location: location) + } /// Trigger a check whether the user is in any of the registered regions and, if so, trigger the primary /// event handler (with case `.manual`). public func checkIfInRegion() async { guard let location = await runUpdateCycle(trigger: .manual) else { return } - - let candidates = regionsToMonitor.filter { $0.contains(location.coordinate) } - guard let closest = candidates.min(by: { lhs, rhs in - let lefty = location.distance(from: .init(latitude: lhs.center.latitude, longitude: lhs.center.longitude)) - let righty = location.distance(from: .init(latitude: rhs.center.latitude, longitude: rhs.center.longitude)) - return lefty < righty - }) else { return } - - eventHandler(.manual(closest, location)) + reportManualEventIfNeeded(location: location) } } @@ -368,14 +396,21 @@ extension GeoMonitor { func monitorCurrentArea() async throws -> CLLocation { dispatchPrecondition(condition: .onQueue(.main)) - + let location = try await fetchCurrentLocation() + _ = monitorCurrentArea(using: location) + return location + } + + @discardableResult + func monitorCurrentArea(using location: CLLocation) -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) // Monitor a radius around it, using a single fixed "my location" circle if let previous = currentLocationRegion as? CLCircularRegion, previous.contains(location.coordinate) { - return location + return false } - + // Monitor new region let region = CLCircularRegion( center: location.coordinate, @@ -396,8 +431,8 @@ extension GeoMonitor { eventHandler(.status("GeoMonitor is monitoring \(MKDistanceFormatter().string(fromDistance: region.radius))...", .updatedCurrentLocationRegion)) // ... continues in `didExitRegion`... - - return location + + return true } func stopMonitoringCurrentArea() { @@ -470,6 +505,35 @@ extension GeoMonitor { let furthestMonitored = toMonitor.compactMap(\.distance).max() eventHandler(.status("Updating monitored regions. \(regions.count) candidates; monitoring \(toMonitor.count) regions; removed \(removedCount), kept \(monitoredAlready.count), added \(newRegion.count); now monitoring \(locationManager.monitoredRegions.count). Furthest is \(furthestMonitored ?? -1).", .updatingMonitoredRegions)) } + + private func reportManualEventIfNeeded(location: CLLocation) { + let candidates = regionsToMonitor.filter { $0.contains(location.coordinate) } + guard let closest = candidates.min(by: { lhs, rhs in + let lefty = location.distance(from: .init(latitude: lhs.center.latitude, longitude: lhs.center.longitude)) + let righty = location.distance(from: .init(latitude: rhs.center.latitude, longitude: rhs.center.longitude)) + return lefty < righty + }) else { + return + } + + guard shouldReportRegion(identifier: closest.identifier, kind: .enteredRegion, context: "manual check") else { + return + } + + eventHandler(.manual(closest, location)) + } + + private func shouldReportRegion(identifier: String, kind: StatusKind, context: String) -> Bool { + let minInterval = config.minIntervalBetweenEnteringSameRegion * -1 + if let lastReport = recentlyReportedRegionIdentifiers.first(where: { $0.0 == identifier }), lastReport.1.timeIntervalSinceNow >= minInterval { + eventHandler(.status("GeoMonitor skipped duplicate \(context) for \(identifier). Last was \(lastReport.1.timeIntervalSinceNow * -1) seconds ago.", kind)) + return false + } + + recentlyReportedRegionIdentifiers.append((identifier, Date())) + recentlyReportedRegionIdentifiers.removeAll { $0.1.timeIntervalSinceNow < minInterval } + return true + } struct AnalyzedRegion { let region: CLCircularRegion @@ -478,6 +542,23 @@ extension GeoMonitor { var keep: Bool } + static func shouldProcessForegroundNudge( + lastLocation: CLLocation?, + lastDate: Date, + location: CLLocation, + date: Date, + minimumDistance: CLLocationDistance, + minimumInterval: TimeInterval + ) -> Bool { + guard let lastLocation else { return true } + + let distance = location.distance(from: lastLocation) + let elapsed = date.timeIntervalSince(lastDate) + + // Process if either a meaningful distance or time threshold has been crossed. + return distance >= minimumDistance || elapsed >= minimumInterval + } + @MainActor static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int, config: Config) -> [AnalyzedRegion] { @@ -541,17 +622,12 @@ extension GeoMonitor: @MainActor CLLocationManagerDelegate { eventHandler(.status("GeoMonitor entered -> \(region)", .enteredRegion)) + guard shouldReportRegion(identifier: region.identifier, kind: .enteredRegion, context: "region enter") else { + return + } + do { let location = try await fetchCurrentLocation() - let minInterval = config.minIntervalBetweenEnteringSameRegion * -1 - if let lastReport = recentlyReportedRegionIdentifiers.first(where: { $0.0 == region.identifier }), lastReport.1.timeIntervalSinceNow >= minInterval { - eventHandler(.status("GeoMonitor reported duplicate for \(region.identifier). Entered \(lastReport.1.timeIntervalSinceNow * -1) seconds ago.", .enteredRegion)) - return // Already reported with `minIntervalBetweenEnteringSameRegion` - } - - recentlyReportedRegionIdentifiers.append((region.identifier, Date())) - recentlyReportedRegionIdentifiers.removeAll { $0.1.timeIntervalSinceNow < minInterval } - eventHandler(.entered(match, location)) } catch { eventHandler(.status("GeoMonitor location fetch failed after entering region -> \(error)", .failure)) diff --git a/Tests/GeoMonitorTests/GeoMonitorTests.swift b/Tests/GeoMonitorTests/GeoMonitorTests.swift index 7992d6d..72800cc 100644 --- a/Tests/GeoMonitorTests/GeoMonitorTests.swift +++ b/Tests/GeoMonitorTests/GeoMonitorTests.swift @@ -5,6 +5,48 @@ import CoreLocation @MainActor final class GeoMonitorTests: XCTestCase { + func testShouldProcessForegroundNudge() { + let last = CLLocation(latitude: -31.95, longitude: 115.86) + let nearSoon = CLLocation(latitude: -31.9505, longitude: 115.8605) + let farSoon = CLLocation(latitude: -31.96, longitude: 115.87) + + XCTAssertTrue(GeoMonitor.shouldProcessForegroundNudge( + lastLocation: nil, + lastDate: .distantPast, + location: nearSoon, + date: Date(), + minimumDistance: 400, + minimumInterval: 15 + )) + + XCTAssertFalse(GeoMonitor.shouldProcessForegroundNudge( + lastLocation: last, + lastDate: Date(), + location: nearSoon, + date: Date().addingTimeInterval(5), + minimumDistance: 400, + minimumInterval: 15 + )) + + XCTAssertTrue(GeoMonitor.shouldProcessForegroundNudge( + lastLocation: last, + lastDate: Date(), + location: farSoon, + date: Date().addingTimeInterval(5), + minimumDistance: 400, + minimumInterval: 15 + )) + + XCTAssertTrue(GeoMonitor.shouldProcessForegroundNudge( + lastLocation: last, + lastDate: Date(), + location: nearSoon, + date: Date().addingTimeInterval(20), + minimumDistance: 400, + minimumInterval: 15 + )) + } + func testManyRegions() async throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct From fd327a3d36159f15f0319acc60cc0a409be523f2 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Tue, 3 Mar 2026 18:17:24 +1100 Subject: [PATCH 6/7] Fix regression: keep full analyzed list, but cap `keep == true` to `max` --- Package.swift | 3 +- Sources/GeoMonitor/GeoMonitor.swift | 80 +++++++++---- Tests/GeoMonitorTests/GeoMonitorTests.swift | 125 ++++++++++---------- 3 files changed, 122 insertions(+), 86 deletions(-) diff --git a/Package.swift b/Package.swift index 59805aa..f2bea22 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,8 @@ import PackageDescription let package = Package( name: "GeoMonitor", platforms: [ - .iOS(.v16) + .iOS(.v16), + .macOS(.v11) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Sources/GeoMonitor/GeoMonitor.swift b/Sources/GeoMonitor/GeoMonitor.swift index cafe578..8bd79b4 100644 --- a/Sources/GeoMonitor/GeoMonitor.swift +++ b/Sources/GeoMonitor/GeoMonitor.swift @@ -133,14 +133,7 @@ public class GeoMonitor: NSObject, ObservableObject { /// Whether it's possible to bring up the system prompt to ask for access to the device's location public var canAsk: Bool { - switch locationManager.authorizationStatus { - case .notDetermined: - return true - case .authorizedAlways, .authorizedWhenInUse, .denied, .restricted: - return false - @unknown default: - return false - } + locationManager.authorizationStatus == .notDetermined } private func updateAccess() { @@ -150,9 +143,11 @@ public class GeoMonitor: NSObject, ObservableObject { // Note: We do NOT update `enableInBackground` here, as that's the user's // setting, i.e., they might not want to have it enabled even though the // app has permissions. +#if !os(macOS) case .authorizedWhenInUse: hasAccess = !needsFullAccuracy || locationManager.accuracyAuthorization == .fullAccuracy enableInBackground = false +#endif case .denied, .notDetermined, .restricted: hasAccess = false enableInBackground = false @@ -161,6 +156,19 @@ public class GeoMonitor: NSObject, ObservableObject { enableInBackground = false } } + + private func shouldRequestBackgroundAuthorization(from status: CLAuthorizationStatus) -> Bool { + switch status { + case .notDetermined: + return true +#if !os(macOS) + case .authorizedWhenInUse: + return true +#endif + default: + return false + } + } public func ask(forBackground: Bool = false, _ handler: @escaping (Bool) -> Void = { _ in }) { if forBackground { @@ -180,7 +188,11 @@ public class GeoMonitor: NSObject, ObservableObject { } } else { self.askHandler = handler +#if os(macOS) + locationManager.requestAlwaysAuthorization() +#else locationManager.requestWhenInUseAuthorization() +#endif } } @@ -256,7 +268,7 @@ public class GeoMonitor: NSObject, ObservableObject { @Published public var enableInBackground: Bool = false { didSet { guard enableInBackground != oldValue else { return } - if enableInBackground, (locationManager.authorizationStatus == .notDetermined || locationManager.authorizationStatus == .authorizedWhenInUse) { + if enableInBackground, shouldRequestBackgroundAuthorization(from: locationManager.authorizationStatus) { ask(forBackground: true) } else if enableInBackground { updateAccess() @@ -480,35 +492,49 @@ extension GeoMonitor { @MainActor static func determineRegionsToMonitor(regions: [CLCircularRegion], location: CLLocation?, max: Int, config: Config) -> [AnalyzedRegion] { - let processed: [AnalyzedRegion] = regions.map { region in let distance = location.map { $0.distance(from: .init(latitude: region.center.latitude, longitude: region.center.longitude)) } let priority = (region as? PrioritizedRegion)?.priority return .init(region: region, distance: distance, priority: priority, keep: true) } - - // Then effectively monitor the nearest + + // Mark nearby candidates first; we keep full analysis output and only toggle `keep`. let nearby = processed.map { analyzed in var updated = analyzed updated.keep = (analyzed.distance ?? 0) < config.maximumDistanceToRegionCenter return updated } - - // The ones to monitor, optionally pruned by either priority or the nearest - guard nearby.count(where: \.keep) > max else { + + let nearbyCount = nearby.count(where: \.keep) + guard nearbyCount > max else { return nearby } - return nearby - .sorted { lhs, rhs in - if let leftDistance = lhs.distance, let rightDistance = rhs.distance, leftDistance > config.maximumDistanceForPriorityPruningCenter || rightDistance > config.maximumDistanceForPriorityPruningCenter { - return leftDistance < rightDistance - } else if let leftPriority = lhs.priority, let rightPriority = rhs.priority, leftPriority != rightPriority { - return leftPriority > rightPriority - } else { - return lhs.region.identifier < rhs.region.identifier + // If over limit, choose the winning subset and mark only those as keep=true. + let selectedIDs = Set( + nearby + .filter(\.keep) + .sorted { lhs, rhs in + if let leftDistance = lhs.distance, let rightDistance = rhs.distance, + leftDistance > config.maximumDistanceForPriorityPruningCenter || rightDistance > config.maximumDistanceForPriorityPruningCenter { + return leftDistance < rightDistance + } else if let leftPriority = lhs.priority, let rightPriority = rhs.priority, leftPriority != rightPriority { + return leftPriority > rightPriority + } else { + return lhs.region.identifier < rhs.region.identifier + } } + .prefix(max) + .map { $0.region.identifier } + ) + + return nearby.map { analyzed in + var updated = analyzed + if updated.keep { + updated.keep = selectedIDs.contains(updated.region.identifier) } + return updated + } } } @@ -642,10 +668,16 @@ extension GeoMonitor: @MainActor CLLocationManagerDelegate { askHandler = { _ in } switch manager.authorizationStatus { - case .authorizedAlways, .authorizedWhenInUse: + case .authorizedAlways: if isMonitoring { startMonitoring() } +#if !os(macOS) + case .authorizedWhenInUse: + if isMonitoring { + startMonitoring() + } +#endif case .denied, .notDetermined, .restricted: return @unknown default: diff --git a/Tests/GeoMonitorTests/GeoMonitorTests.swift b/Tests/GeoMonitorTests/GeoMonitorTests.swift index 7992d6d..09dcf27 100644 --- a/Tests/GeoMonitorTests/GeoMonitorTests.swift +++ b/Tests/GeoMonitorTests/GeoMonitorTests.swift @@ -1,15 +1,14 @@ -import XCTest +import Foundation import CoreLocation +import Testing @testable import GeoMonitor -@MainActor -final class GeoMonitorTests: XCTestCase { - func testManyRegions() async throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - +@Suite("GeoMonitor") +struct GeoMonitorTests { + @Test + @MainActor + func manyRegions() { let regions: [PrioritizedRegion] = [ .init(-31.959492, 115.87516, 900, 400), .init(-31.953156, 115.877762, 900, 400), @@ -59,66 +58,70 @@ final class GeoMonitorTests: XCTestCase { .init(-31.951407, 115.861664, 172, 150), .init(-31.943138, 115.854218, 295, 150), .init(-31.943686, 115.922653, 194, 150), - .init(-31.873867, 115.76548, 151, 150), - .init(-31.943686, 115.922653, 226, 150), - .init(-31.9938, 115.913, 127, 150), - .init(-31.943686, 115.922653, 217, 150), - .init(-31.873867, 115.76548, 175, 150), - .init(-31.951407, 115.861664, 241, 150), - .init(-31.899336, 115.971687, 212, 150), - .init(-31.913763, 115.823273, 315, 150), - .init(-31.957066, 115.859146, 168, 150), - .init(-31.907438, 115.821877, 817, 150), - .init(-31.953903, 115.8945, 425, 150), - .init(-31.940687, 116.015968, 127, 150), - .init(-31.947477, 115.878456, 235, 150), - .init(-31.940687, 116.015968, 130, 150), - .init(-31.958574, 115.858421, 451, 150), - .init(-31.907438, 115.821877, 799, 150), - .init(-31.907438, 115.821877, 804, 150), - .init(-31.947477, 115.878456, 234, 150), - .init(-31.947477, 115.878456, 223, 150), - .init(-31.947477, 115.878456, 230, 150), - .init(-31.947477, 115.878456, 234, 150), - .init(-31.96996, 115.893616, 122, 150), - .init(-31.96996, 115.893616, 122, 150), - .init(-31.873867, 115.76548, 158, 150), - .init(-31.913763, 115.823273, 400,150), + .init(-31.873867, 115.76548, 151, 150), + .init(-31.943686, 115.922653, 226, 150), + .init(-31.9938, 115.913, 127, 150), + .init(-31.943686, 115.922653, 217, 150), + .init(-31.873867, 115.76548, 175, 150), + .init(-31.951407, 115.861664, 241, 150), + .init(-31.899336, 115.971687, 212, 150), + .init(-31.913763, 115.823273, 315, 150), + .init(-31.957066, 115.859146, 168, 150), + .init(-31.907438, 115.821877, 817, 150), + .init(-31.953903, 115.8945, 425, 150), + .init(-31.940687, 116.015968, 127, 150), + .init(-31.947477, 115.878456, 235, 150), + .init(-31.940687, 116.015968, 130, 150), + .init(-31.958574, 115.858421, 451, 150), + .init(-31.907438, 115.821877, 799, 150), + .init(-31.907438, 115.821877, 804, 150), + .init(-31.947477, 115.878456, 234, 150), + .init(-31.947477, 115.878456, 223, 150), + .init(-31.947477, 115.878456, 230, 150), + .init(-31.947477, 115.878456, 234, 150), + .init(-31.96996, 115.893616, 122, 150), + .init(-31.96996, 115.893616, 122, 150), + .init(-31.873867, 115.76548, 158, 150), + .init(-31.913763, 115.823273, 400, 150), .init(-31.8883, 115.801453, 148, 150), - .init(-31.907438, 115.821877, 672, 150), - .init(-31.953903, 115.8945, 280, 150), - .init(-31.943138, 115.854218, 291, 150), - .init(-31.96996, 115.893616, 126, 150), - .init(-31.907438, 115.821877, 529, 150), - .init(-31.953903, 115.8945, 137, 150), - .init(-31.958574, 115.858421, 357, 150), - .init(-31.958574, 115.858421, 511, 150), - .init(-31.958574, 115.858421, 508, 150), - .init(-31.958574, 115.858421, 533, 150), - .init(-32.012253, 115.856537, 145, 150), - .init(-32.012253, 115.856537, 184, 150), - .init(-31.907438, 115.821877, 827, 150), - .init(-31.953903, 115.8945, 435, 150), - .init(-31.940687, 116.015968, 191, 150), - .init(-31.940687, 116.015968, 156, 150), - .init(-31.958574, 115.858421, 486, 150), + .init(-31.907438, 115.821877, 672, 150), + .init(-31.953903, 115.8945, 280, 150), + .init(-31.943138, 115.854218, 291, 150), + .init(-31.96996, 115.893616, 126, 150), + .init(-31.907438, 115.821877, 529, 150), + .init(-31.953903, 115.8945, 137, 150), + .init(-31.958574, 115.858421, 357, 150), + .init(-31.958574, 115.858421, 511, 150), + .init(-31.958574, 115.858421, 508, 150), + .init(-31.958574, 115.858421, 533, 150), + .init(-32.012253, 115.856537, 145, 150), + .init(-32.012253, 115.856537, 184, 150), + .init(-31.907438, 115.821877, 827, 150), + .init(-31.953903, 115.8945, 435, 150), + .init(-31.940687, 116.015968, 191, 150), + .init(-31.940687, 116.015968, 156, 150), + .init(-31.958574, 115.858421, 486, 150), ] let needle = CLLocation(latitude: -31.9586, longitude: 115.8681) let withoutLocation = GeoMonitor.determineRegionsToMonitor(regions: regions, location: nil, max: 19, config: .default) - XCTAssertEqual(withoutLocation.count, 19) - XCTAssertFalse(withoutLocation.allSatisfy { needle.distance(from: .init(latitude: $0.center.latitude, longitude: $0.center.longitude)) <= 5_000 }) - XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).min() ?? 0, 529) // highest priorities - XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).max(), 900) - XCTAssertEqual(withoutLocation.compactMap { $0 as? PrioritizedRegion }.filter { $0.priority == 900 }.count, 8) // all top priorities included - + let withoutLocationKept = withoutLocation.filter(\.keep) + #expect(withoutLocation.count == regions.count) + #expect(withoutLocationKept.count == 19) + #expect(!withoutLocationKept.allSatisfy { needle.distance(from: .init(latitude: $0.region.center.latitude, longitude: $0.region.center.longitude)) <= 5_000 }) + #expect((withoutLocationKept.compactMap(\.priority).min() ?? 0) == 529) + #expect((withoutLocationKept.compactMap(\.priority).max() ?? 0) == 900) + #expect(withoutLocationKept.filter { $0.priority == 900 }.count == 8) + let withLocation = GeoMonitor.determineRegionsToMonitor(regions: regions, location: needle, max: 19, config: .default) - XCTAssertEqual(withLocation.count, 19) - XCTAssertTrue(withLocation.allSatisfy { needle.distance(from: .init(latitude: $0.center.latitude, longitude: $0.center.longitude)) <= 5_000 }) - XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).min() ?? 0, 349) // highest priorities - XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.map(\.priority).max(), 900) - XCTAssertEqual(withLocation.compactMap { $0 as? PrioritizedRegion }.filter { $0.priority == 900 }.count, 8) // all top priorities included + let withLocationKept = withLocation.filter(\.keep) + #expect(withLocation.count == regions.count) + #expect(withLocationKept.count == 19) + #expect(withLocationKept.allSatisfy { needle.distance(from: .init(latitude: $0.region.center.latitude, longitude: $0.region.center.longitude)) <= 5_000 }) + #expect((withLocationKept.compactMap(\.priority).min() ?? 0) == 349) + #expect((withLocationKept.compactMap(\.priority).max() ?? 0) == 900) + #expect(withLocationKept.filter { $0.priority == 900 }.count == 8) } } From b9e4b676f59d9d25ee93e61cbd69d6a84b55128d Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 4 Mar 2026 18:51:42 +1100 Subject: [PATCH 7/7] Robustness and logging for foreground fetches - New foreground-related enum values for better logging - Fetch regions also on foreground triggers - Ignore foreground update with poor accuracy - Lower threshold --- Sources/GeoMonitor/GeoMonitor.swift | 96 ++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 10 deletions(-) diff --git a/Sources/GeoMonitor/GeoMonitor.swift b/Sources/GeoMonitor/GeoMonitor.swift index bc67270..9ddbd87 100644 --- a/Sources/GeoMonitor/GeoMonitor.swift +++ b/Sources/GeoMonitor/GeoMonitor.swift @@ -29,12 +29,15 @@ public class GeoMonitor: NSObject, ObservableObject { public var currentLocationFetchRecency: TimeInterval = 10 public var minIntervalBetweenEnteringSameRegion: TimeInterval = 120 public var foregroundNudgeMaximumHorizontalAccuracy: CLLocationAccuracy = 250 - public var foregroundNudgeMinimumDistance: CLLocationDistance = 400 - public var foregroundNudgeMinimumInterval: TimeInterval = 15 + public var foregroundNudgeMinimumDistance: CLLocationDistance = 150 + public var foregroundNudgeMinimumInterval: TimeInterval = 5 + public var foregroundContainmentAccuracyBuffer: CLLocationDistance = 100 + public var trackingDistanceFilter: CLLocationDistance = 100 } public enum FetchTrigger: String, Sendable { case manual + case foreground case initial case visitMonitoring case regionMonitoring @@ -54,6 +57,7 @@ public class GeoMonitor: NSObject, ObservableObject { public enum StatusKind { case updatingMonitoredRegions case updatedCurrentLocationRegion + case foregroundNudge case enteredRegion case visitMonitoring case stateChange @@ -67,6 +71,9 @@ public class GeoMonitor: NSObject, ObservableObject { /// When user is currently in a region, triggered from calling `checkIfInRegion()` case manual(CLCircularRegion, CLLocation?) + /// When user is currently in a region, triggered by a live foreground location update. + case foreground(CLCircularRegion, CLLocation?) + /// Internal status message, useful for debugging; should not be shown to user case status(String, StatusKind) } @@ -85,6 +92,25 @@ public class GeoMonitor: NSObject, ObservableObject { private var lastForegroundNudgeLocation: CLLocation? private var lastForegroundNudgeAt: Date = .distantPast + private enum ManualCheckSource { + case manual + case foreground + + var context: String { + switch self { + case .manual: return "manual check" + case .foreground: return "foreground check" + } + } + + var statusKind: StatusKind { + switch self { + case .manual: return .enteredRegion + case .foreground: return .foregroundNudge + } + } + } + public var maxRegionsToMonitor = 20 /// Set to `true` if the `hasAccuracy` values should also check whether the user @@ -211,7 +237,7 @@ public class GeoMonitor: NSObject, ObservableObject { didSet { if isTracking { locationManager.desiredAccuracy = kCLLocationAccuracyKilometer - locationManager.distanceFilter = 250 + locationManager.distanceFilter = config.trackingDistanceFilter locationManager.startUpdatingLocation() } else { locationManager.stopUpdatingLocation() @@ -355,6 +381,10 @@ public class GeoMonitor: NSObject, ObservableObject { guard isMonitoring else { return } guard location.horizontalAccuracy >= 0, location.horizontalAccuracy <= config.foregroundNudgeMaximumHorizontalAccuracy else { + eventHandler(.status( + "GeoMonitor skipped foreground nudge due to accuracy \(location.horizontalAccuracy)m > \(config.foregroundNudgeMaximumHorizontalAccuracy)m.", + .foregroundNudge + )) return } @@ -367,22 +397,30 @@ public class GeoMonitor: NSObject, ObservableObject { minimumDistance: config.foregroundNudgeMinimumDistance, minimumInterval: config.foregroundNudgeMinimumInterval ) else { + let distance = lastForegroundNudgeLocation.map { location.distance(from: $0) } ?? 0 + let elapsed = now.timeIntervalSince(lastForegroundNudgeAt) + eventHandler(.status( + "GeoMonitor skipped foreground nudge due to throttle. Distance \(distance)m in \(elapsed)s (needs \(config.foregroundNudgeMinimumDistance)m or \(config.foregroundNudgeMinimumInterval)s).", + .foregroundNudge + )) return } lastForegroundNudgeLocation = location lastForegroundNudgeAt = now + let regions = await fetchSource.fetchRegions(trigger: .foreground) + regionsToMonitor = regions _ = monitorCurrentArea(using: location) - monitorDebounced(regionsToMonitor, location: location, delay: 0.5) - reportManualEventIfNeeded(location: location) + monitorDebounced(regions, location: location, delay: 0.5) + reportManualEventIfNeeded(location: location, source: .foreground) } /// Trigger a check whether the user is in any of the registered regions and, if so, trigger the primary /// event handler (with case `.manual`). public func checkIfInRegion() async { guard let location = await runUpdateCycle(trigger: .manual) else { return } - reportManualEventIfNeeded(location: location) + reportManualEventIfNeeded(location: location, source: .manual) } } @@ -518,21 +556,59 @@ extension GeoMonitor { eventHandler(.status("Updating monitored regions. \(regions.count) candidates; monitoring \(toMonitor.count) regions; removed \(removedCount), kept \(monitoredAlready.count), added \(newRegion.count); now monitoring \(locationManager.monitoredRegions.count). Furthest is \(furthestMonitored ?? -1).", .updatingMonitoredRegions)) } - private func reportManualEventIfNeeded(location: CLLocation) { - let candidates = regionsToMonitor.filter { $0.contains(location.coordinate) } + private func reportManualEventIfNeeded(location: CLLocation, source: ManualCheckSource) { + let accuracyBuffer = source == .foreground + ? max(0, min(location.horizontalAccuracy, config.foregroundContainmentAccuracyBuffer)) + : 0 + + let candidates = regionsToMonitor.filter { region in + if region.contains(location.coordinate) { + return true + } + + guard accuracyBuffer > 0 else { + return false + } + + let distance = location.distance(from: .init(latitude: region.center.latitude, longitude: region.center.longitude)) + return distance <= region.radius + accuracyBuffer + } + guard let closest = candidates.min(by: { lhs, rhs in let lefty = location.distance(from: .init(latitude: lhs.center.latitude, longitude: lhs.center.longitude)) let righty = location.distance(from: .init(latitude: rhs.center.latitude, longitude: rhs.center.longitude)) return lefty < righty }) else { + if let nearest = regionsToMonitor.min(by: { lhs, rhs in + let lefty = location.distance(from: .init(latitude: lhs.center.latitude, longitude: lhs.center.longitude)) + let righty = location.distance(from: .init(latitude: rhs.center.latitude, longitude: rhs.center.longitude)) + return lefty < righty + }) { + let nearestDistance = location.distance(from: .init(latitude: nearest.center.latitude, longitude: nearest.center.longitude)) + let edgeDistance = max(0, nearestDistance - nearest.radius) + eventHandler(.status( + "GeoMonitor \(source.context) found no region containing current location. Closest: \(nearest.identifier), edge distance \(edgeDistance)m, location accuracy \(location.horizontalAccuracy)m, buffer \(accuracyBuffer)m.", + source.statusKind + )) + } else { + eventHandler(.status( + "GeoMonitor \(source.context) found no regions to evaluate.", + source.statusKind + )) + } return } - guard shouldReportRegion(identifier: closest.identifier, kind: .enteredRegion, context: "manual check") else { + guard shouldReportRegion(identifier: closest.identifier, kind: source.statusKind, context: source.context) else { return } - eventHandler(.manual(closest, location)) + switch source { + case .manual: + eventHandler(.manual(closest, location)) + case .foreground: + eventHandler(.foreground(closest, location)) + } } private func shouldReportRegion(identifier: String, kind: StatusKind, context: String) -> Bool {