287 lines
9.8 KiB
Swift
287 lines
9.8 KiB
Swift
import CoreLocation
|
|
import Foundation
|
|
import PromiseKit
|
|
|
|
public extension CLLocationManager {
|
|
static func oneShotLocation(timeout: TimeInterval) -> Promise<CLLocation> {
|
|
OneShotLocationProxy(
|
|
locationManager: CLLocationManager(),
|
|
timeout: after(seconds: timeout)
|
|
).promise
|
|
}
|
|
}
|
|
|
|
enum OneShotError: Error, Equatable, LocalizedError, CustomNSError {
|
|
case clError(CLError)
|
|
case outOfTime
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .clError(error):
|
|
return error.localizedDescription
|
|
case .outOfTime:
|
|
return L10n.ClError.Description.locationUnknown
|
|
}
|
|
}
|
|
|
|
static var errorDomain: String {
|
|
"OneShotError"
|
|
}
|
|
|
|
var errorCode: Int {
|
|
switch self {
|
|
case let .clError(error): return 1000 + error.code.rawValue
|
|
case .outOfTime: return 1
|
|
}
|
|
}
|
|
|
|
static func == (lhs: OneShotError, rhs: OneShotError) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case let (.clError(lhsClError), .clError(rhsClError)):
|
|
return lhsClError.code == rhsClError.code
|
|
case (.outOfTime, .outOfTime):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PotentialLocation: Comparable, CustomStringConvertible {
|
|
static func desiredAccuracy(for accuracy: CLAccuracyAuthorization) -> CLLocationAccuracy {
|
|
switch accuracy {
|
|
case .fullAccuracy: return 100.0
|
|
case .reducedAccuracy: return 3000.0
|
|
@unknown default: return 100.0
|
|
}
|
|
}
|
|
|
|
static func invalidAccuracyThreshold(for accuracy: CLAccuracyAuthorization) -> CLLocationAccuracy {
|
|
switch accuracy {
|
|
case .fullAccuracy: return 1500.0
|
|
case .reducedAccuracy: return .greatestFiniteMagnitude
|
|
@unknown default: return .greatestFiniteMagnitude
|
|
}
|
|
}
|
|
|
|
static var desiredAge: TimeInterval { 30.0 }
|
|
static var invalidAgeThreshold: TimeInterval { 600.0 }
|
|
|
|
enum Quality {
|
|
case invalid
|
|
case meh
|
|
case perfect
|
|
}
|
|
|
|
let location: CLLocation
|
|
let quality: Quality
|
|
|
|
init(location: CLLocation, accuracyAuthorization: CLAccuracyAuthorization) {
|
|
do {
|
|
self.location = try location.sanitized()
|
|
} catch {
|
|
Current.Log.error("Location \(location.coordinate) couldn't be sanitized: \(error)")
|
|
self.quality = .invalid
|
|
self.location = location
|
|
return
|
|
}
|
|
|
|
func isBadCoordinateValue(_ value: Double) -> Bool {
|
|
// this is within 110µm of 0.0 latitude/longitude and is very unlikely to really happen
|
|
(value >= 0 && value <= 0.000000001) || (value >= -0.000000001 && value <= 0)
|
|
}
|
|
|
|
if isBadCoordinateValue(location.coordinate.latitude) || isBadCoordinateValue(location.coordinate.longitude) {
|
|
// iOS 13.5? seems to occasionally report 0 lat/long, so ignore these locations
|
|
// iOS 15? seems to occasionally report ``9.368162246e-315 (or similar small values), so ignore these too
|
|
Current.Log.error("Location \(location.coordinate) was super duper invalid")
|
|
self.quality = .invalid
|
|
} else {
|
|
// now = 0 seconds ago
|
|
// timestamp = 100 seconds ago
|
|
// so age is the positive number of seconds since this update
|
|
let age = Current.date().timeIntervalSince(location.timestamp)
|
|
let desiredAccuracy = Self.desiredAccuracy(for: accuracyAuthorization)
|
|
let invalidAccuracyThreshold = Self.invalidAccuracyThreshold(for: accuracyAuthorization)
|
|
|
|
if location.horizontalAccuracy <= desiredAccuracy && age <= Self.desiredAge {
|
|
self.quality = .perfect
|
|
} else if location.horizontalAccuracy > invalidAccuracyThreshold || age > Self.invalidAgeThreshold {
|
|
self.quality = .invalid
|
|
} else {
|
|
self.quality = .meh
|
|
}
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
"coordinate \(location.coordinate) accuracy \(accuracy) from \(timestamp) quality \(quality)"
|
|
}
|
|
|
|
var accuracy: CLLocationAccuracy {
|
|
location.horizontalAccuracy
|
|
}
|
|
|
|
var timestamp: Date {
|
|
location.timestamp
|
|
}
|
|
|
|
static func == (lhs: PotentialLocation, rhs: PotentialLocation) -> Bool {
|
|
lhs.location == rhs.location
|
|
}
|
|
|
|
static func < (lhs: PotentialLocation, rhs: PotentialLocation) -> Bool {
|
|
switch (lhs.quality, rhs.quality) {
|
|
case (.perfect, .perfect):
|
|
// both are 'perfect' so prefer the newer one
|
|
return lhs.timestamp < rhs.timestamp
|
|
case (.perfect, .meh),
|
|
(.perfect, .invalid),
|
|
(.meh, .invalid):
|
|
// lhs is better, so it's 'greater'
|
|
return false
|
|
case (.meh, .perfect),
|
|
(.invalid, .perfect),
|
|
(.invalid, .meh):
|
|
// rhs is better, so it's 'greater'
|
|
return true
|
|
case (.meh, .meh):
|
|
// neither are perfect, which is more recent?
|
|
// if the time difference is a lot, prefer the more recent, even if less accurate
|
|
if lhs.timestamp.timeIntervalSince(rhs.timestamp) > 60 {
|
|
// lhs is more than a minute newer, prefer it
|
|
return false
|
|
} else if rhs.timestamp.timeIntervalSince(lhs.timestamp) > 60 {
|
|
// rhs is more than a minute newer, prefer it
|
|
return true
|
|
} else {
|
|
// prefer whichever is more accurate, since they're close in time to each other
|
|
return lhs.accuracy > rhs.accuracy
|
|
}
|
|
case (.invalid, .invalid):
|
|
// nobody cares
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
final class OneShotLocationProxy: NSObject, CLLocationManagerDelegate {
|
|
private(set) var promise: Promise<CLLocation>
|
|
private let seal: Resolver<CLLocation>
|
|
private let locationManager: CLLocationManager
|
|
private var selfRetain: OneShotLocationProxy?
|
|
private var potentialLocations: [PotentialLocation] = [] {
|
|
didSet {
|
|
precondition(Thread.isMainThread)
|
|
}
|
|
}
|
|
|
|
init(
|
|
locationManager: CLLocationManager,
|
|
timeout: Guarantee<Void>
|
|
) {
|
|
precondition(Thread.isMainThread)
|
|
|
|
(self.promise, self.seal) = Promise<CLLocation>.pending()
|
|
self.locationManager = locationManager
|
|
|
|
Current.isPerformingSingleShotLocationQuery = true
|
|
|
|
super.init()
|
|
|
|
locationManager.allowsBackgroundLocationUpdates = !Current.isAppExtension
|
|
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
|
locationManager.delegate = self
|
|
|
|
self.selfRetain = self
|
|
locationManager.startUpdatingLocation()
|
|
self.promise = promise.ensure {
|
|
locationManager.stopUpdatingLocation()
|
|
locationManager.delegate = nil
|
|
self.selfRetain = nil
|
|
|
|
Current.isPerformingSingleShotLocationQuery = false
|
|
}
|
|
|
|
timeout.done { [weak self] in
|
|
// we can be weak here because the alternative is that we're already resolved
|
|
self?.checkPotentialLocations(outOfTime: true)
|
|
}
|
|
|
|
if let cachedLocation = locationManager.location {
|
|
let authorization: CLAccuracyAuthorization = locationManager.accuracyAuthorization
|
|
let potentialLocation = PotentialLocation(location: cachedLocation, accuracyAuthorization: authorization)
|
|
potentialLocations.append(potentialLocation)
|
|
}
|
|
}
|
|
|
|
private func checkPotentialLocations(outOfTime: Bool) {
|
|
precondition(Thread.isMainThread)
|
|
|
|
guard !promise.isResolved else {
|
|
return
|
|
}
|
|
|
|
let bestLocation = potentialLocations.sorted().last
|
|
|
|
if let bestLocation {
|
|
switch bestLocation.quality {
|
|
case .perfect:
|
|
Current.Log.info("Got a perfect location!")
|
|
seal.fulfill(bestLocation.location)
|
|
case .invalid:
|
|
if outOfTime {
|
|
Current.Log.error("Out of time with only invalid location!")
|
|
seal.reject(OneShotError.outOfTime)
|
|
}
|
|
case .meh:
|
|
if outOfTime {
|
|
Current.Log.info("Out of time with a meh location")
|
|
seal.fulfill(bestLocation.location)
|
|
}
|
|
}
|
|
} else if outOfTime {
|
|
Current.Log.info("Out of time without any location!")
|
|
seal.reject(OneShotError.outOfTime)
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
precondition(Thread.isMainThread)
|
|
|
|
let authorization: CLAccuracyAuthorization = manager.accuracyAuthorization
|
|
let updatedPotentialLocations = locations.map {
|
|
PotentialLocation(location: $0, accuracyAuthorization: authorization)
|
|
}
|
|
Current.Log.verbose("got raw locations \(locations) and turned into potential: \(updatedPotentialLocations)")
|
|
potentialLocations.append(contentsOf: updatedPotentialLocations)
|
|
checkPotentialLocations(outOfTime: false)
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
precondition(Thread.isMainThread)
|
|
|
|
let failError: Error
|
|
|
|
if let clErr = error as? CLError {
|
|
let realm = Current.realm()
|
|
realm.reentrantWrite {
|
|
let locErr = LocationError(err: clErr)
|
|
realm.add(locErr)
|
|
}
|
|
|
|
Current.Log.error("Received CLError: \(clErr)")
|
|
failError = OneShotError.clError(clErr)
|
|
} else {
|
|
Current.Log.error("Received non-CLError when we only expected CLError: \(error)")
|
|
failError = error
|
|
}
|
|
|
|
if potentialLocations.isEmpty {
|
|
seal.reject(failError)
|
|
} else {
|
|
checkPotentialLocations(outOfTime: true)
|
|
}
|
|
}
|
|
}
|