iOS/Sources/Shared/API/Webhook/Sensors/GeocoderSensor.swift

165 lines
6.2 KiB
Swift

import Contacts
import CoreLocation
import Foundation
import PromiseKit
public class GeocoderSensor: SensorProvider {
public enum GeocoderError: Error {
case noLocation
}
enum UserDefaultsKeys: String {
case geocodeUseZone = "geocoded_location_use_zone"
var title: String {
switch self {
case .geocodeUseZone: return L10n.Sensors.GeocodedLocation.Setting.useZones
}
}
}
public let request: SensorProviderRequest
public required init(request: SensorProviderRequest) {
self.request = request
}
public func sensors() -> Promise<[WebhookSensor]> {
firstly { () -> Promise<[CLPlacemark]> in
guard let location = request.location else {
throw GeocoderError.noLocation
}
return Current.geocoder.geocode(location)
}.recover { [request] (error: Error) -> Promise<[CLPlacemark]> in
guard case GeocoderError.noLocation = error, case .registration = request.reason else { throw error }
return .value([])
}.map { [request] (placemarks: [CLPlacemark]) -> [WebhookSensor] in
let sensor = with(WebhookSensor(name: "Geocoded Location", uniqueID: "geocoded_location")) {
$0.State = "Unknown"
$0.Icon = "mdi:\(MaterialDesignIcons.mapIcon.name)"
$0.Settings = [
.init(type: .switch(getter: {
Current.settingsStore.prefs.bool(forKey: UserDefaultsKeys.geocodeUseZone.rawValue)
}, setter: {
Current.settingsStore.prefs.set($0, forKey: UserDefaultsKeys.geocodeUseZone.rawValue)
}), title: UserDefaultsKeys.geocodeUseZone.title),
]
}
guard !placemarks.isEmpty else {
return [sensor]
}
let address: String? = placemarks
.compactMap(Self.postalAddress(for:))
.map { CNPostalAddressFormatter.string(from: $0, style: .mailingAddress) }
.first(where: { $0.isEmpty == false })
if let address {
sensor.State = address
}
var attributes = Self.attributes(for: placemarks)
if let location = request.location {
let insideZones = Current.realm().objects(RLMZone.self)
.filter(RLMZone.trackablePredicate)
.sorted(byKeyPath: "Radius")
.filter { $0.circularRegion.contains(location.coordinate) }
.map { $0.FriendlyName ?? $0.Name }
.filter { $0 != "" }
if let zone = insideZones.first,
Current.settingsStore.prefs.bool(forKey: UserDefaultsKeys.geocodeUseZone.rawValue) {
// only override if there's something to set, and only if the user wants us to do so
sensor.State = zone
}
// needs to be explicitly typed or the JSON encoding will barf
attributes["Zones"] = Array(insideZones)
}
sensor.Attributes = attributes
return [sensor]
}
}
private static func attributes(for placemarks: [CLPlacemark]) -> [String: Any] {
let bestLocation = Self.best(from: placemarks, keyPath: \.location)
func value(_ keyPath: KeyPath<CLPlacemark, String?>) -> String {
Self.best(from: placemarks, keyPath: keyPath) ?? "N/A"
}
return [
"Administrative Area": value(\.administrativeArea),
"Areas Of Interest": Self.best(from: placemarks, keyPath: \.areasOfInterest) ?? "N/A",
"Country": value(\.country),
"Inland Water": value(\.inlandWater),
"ISO Country Code": value(\.isoCountryCode),
"Locality": value(\.locality),
"Location": bestLocation.flatMap { [$0.coordinate.latitude, $0.coordinate.longitude] } ?? "N/A",
"Name": value(\.name),
"Ocean": value(\.ocean),
"Postal Code": value(\.postalCode),
"Sub Administrative Area": value(\.subAdministrativeArea),
"Sub Locality": value(\.subLocality),
"Sub Thoroughfare": value(\.subThoroughfare),
"Thoroughfare": value(\.thoroughfare),
"Time Zone": Self.best(from: placemarks, keyPath: \.timeZone?.identifier) ?? TimeZone.current.identifier,
]
}
private static func best<ElementType, ReturnType>(
from: [ElementType],
keyPath: KeyPath<ElementType, ReturnType?>
) -> ReturnType? {
let results = from.compactMap { $0[keyPath: keyPath] }
if let nonEmpty = results.first(where: { ($0 as? String)?.isEmpty == false }) {
return nonEmpty
} else {
return results.first
}
}
private static func postalAddress(for placemark: CLPlacemark) -> CNPostalAddress? {
if let address = placemark.postalAddress {
return address
}
return with(CNMutablePostalAddress()) {
$0.street = placemark.thoroughfare ?? ""
$0.city = placemark.locality ?? ""
$0.state = placemark.administrativeArea ?? ""
$0.postalCode = placemark.postalCode ?? ""
// matching behavior with iOS 11+, prefer iso country code
$0.country = placemark.isoCountryCode ?? placemark.country ?? ""
if #available(iOS 10.3, *) {
$0.subLocality = placemark.subLocality ?? ""
$0.subAdministrativeArea = placemark.subAdministrativeArea ?? ""
}
}
}
}
public extension CLGeocoder {
static func geocode(location: CLLocation) -> Promise<[CLPlacemark]> {
Promise { seal in
let geocoder = CLGeocoder()
var strongGeocoder: CLGeocoder? = geocoder
let completionHandler: ([CLPlacemark]?, Error?) -> Void = { results, error in
withExtendedLifetime(strongGeocoder) {
seal.resolve(results, error)
strongGeocoder = nil
}
}
geocoder.reverseGeocodeLocation(location, preferredLocale: nil, completionHandler: completionHandler)
}
}
}