Commit dcd92a0e by Demid Merzlyakov

Alerts: fetching and parsing.

parent 3d8b7b8f
...@@ -21,6 +21,7 @@ public class LocationManager { ...@@ -21,6 +21,7 @@ public class LocationManager {
private let weatherUpdateSource: WeatherSource private let weatherUpdateSource: WeatherSource
private let healthSource: HealthSource private let healthSource: HealthSource
private let nwsAlertsManager: NWSAlertsManager
private let storage: Storage private let storage: Storage
private var defaultLocation = Location(deviceLocation: false, private var defaultLocation = Location(deviceLocation: false,
coordinates: .init(latitude: 37.3230, longitude: -122.0322), // Cupertino coordinates: .init(latitude: 37.3230, longitude: -122.0322), // Cupertino
...@@ -149,13 +150,14 @@ public class LocationManager { ...@@ -149,13 +150,14 @@ public class LocationManager {
} }
} }
public static let shared = LocationManager(weatherUpdateSource: WdtWeatherSource(), healthSource: BlendHealthSource(), storage: CoreDataStorage()) public static let shared = LocationManager(weatherUpdateSource: WdtWeatherSource(), healthSource: BlendHealthSource(), nwsAlertsManager: NWSAlertsManager(), storage: CoreDataStorage())
public let maxLocationsCount = 12 public let maxLocationsCount = 12
public init(weatherUpdateSource: WeatherSource, healthSource: HealthSource, storage: Storage) { public init(weatherUpdateSource: WeatherSource, healthSource: HealthSource, nwsAlertsManager: NWSAlertsManager, storage: Storage) {
self.weatherUpdateSource = weatherUpdateSource self.weatherUpdateSource = weatherUpdateSource
self.healthSource = healthSource self.healthSource = healthSource
self.deviceLocationMonitor = DeviceLocationMonitor() self.deviceLocationMonitor = DeviceLocationMonitor()
self.nwsAlertsManager = nwsAlertsManager
self.storage = storage self.storage = storage
self.deviceLocationMonitor.delegate = self self.deviceLocationMonitor.delegate = self
...@@ -181,6 +183,7 @@ public class LocationManager { ...@@ -181,6 +183,7 @@ public class LocationManager {
log.info("Update all: update default location if needed.") log.info("Update all: update default location if needed.")
updateWeather(for: defaultLocation, updateType: .full) updateWeather(for: defaultLocation, updateType: .full)
updateHealth(for: defaultLocation) updateHealth(for: defaultLocation)
updateNotifications(for: defaultLocation)
return return
} }
log.info("Update all \(locations.count) locations if needed...") log.info("Update all \(locations.count) locations if needed...")
...@@ -192,6 +195,7 @@ public class LocationManager { ...@@ -192,6 +195,7 @@ public class LocationManager {
updateWeather(for: location, updateType: .preferIncremental) updateWeather(for: location, updateType: .preferIncremental)
} }
updateHealth(for: location) updateHealth(for: location)
updateNotifications(for: location)
} }
} }
...@@ -229,6 +233,41 @@ public class LocationManager { ...@@ -229,6 +233,41 @@ public class LocationManager {
} }
} }
public func updateNotifications(for location: Location) {
if let lastTimeUpdated = location.notifications?.updatedAt {
guard Date().timeIntervalSince(lastTimeUpdated) >= nwsAlertsManager.updateInterval else {
log.info("Update notifications (\(location)): fresh enough (last updated at \(lastTimeUpdated), skip update")
return
}
}
log.info("Update notifications for \(location)")
nwsAlertsManager.fetchActiveAlerts(for: location) { [weak self] (alerts, error) in
guard let self = self else { return }
guard let alerts = alerts else {
if let error = error {
self.log.error("Update notifications (\(location)) error: \(error)")
}
else {
self.log.error("Update notifications (\(location)) error: unknown error")
}
return
}
let updatedNotifications = Notifications(updatedAt: Date(), nwsAlers: alerts)
self.log.info("Update notifications (\(location)): success.")
DispatchQueue.main.async {
if let indexToUpdate = self.locations.firstIndex(where: { $0 == location }) {
self.locations[indexToUpdate].notifications = updatedNotifications
}
else if self.defaultLocation == location {
self.defaultLocation.notifications = updatedNotifications
}
else {
self.log.warning("Update notifications: Failed to find location after update. Maybe it was deleted while the update was performed. Maybe something went wrong. Location: \(location)")
}
}
}
}
public func updateWeather(for location: Location?, updateType: WeatherUpdateType) { public func updateWeather(for location: Location?, updateType: WeatherUpdateType) {
guard let location = location else { guard let location = location else {
log.warning("Update weather: empty location.") log.warning("Update weather: empty location.")
......
...@@ -15,6 +15,7 @@ public struct Location { ...@@ -15,6 +15,7 @@ public struct Location {
public var lastWeatherUpdateDate: Date? public var lastWeatherUpdateDate: Date?
public var coordinates: CLLocationCoordinate2D? public var coordinates: CLLocationCoordinate2D?
public var imageName: String? = "ny_bridge" //we'll possibly need to switch to URL public var imageName: String? = "ny_bridge" //we'll possibly need to switch to URL
public var notifications: Notifications?
public var countryCode: String? { public var countryCode: String? {
didSet { didSet {
......
//
// Notification.swift
// 1Weather
//
// Created by Demid Merzlyakov on 14.04.2021.
//
import Foundation
protocol AppNotification {
var date: Date { get }
var title: String { get }
var text: String? { get }
var location: Location? { get }
var expires: Date? { get }
var expired: Bool { get }
}
extension AppNotification {
var expired: Bool {
guard let expires = expires else {
return false
}
return Date() < expires
}
}
//
// Notifications.swift
// 1Weather
//
// Created by Demid Merzlyakov on 20.04.2021.
//
import Foundation
public struct Notifications {
public let updatedAt: Date
public let nwsAlers: [NWSAlert]
}
//
// NWSAlert.swift
// 1Weather
//
// Created by Demid Merzlyakov on 14.04.2021.
//
import Foundation
struct WeatherAlert: AppNotification {
var date: Date
var title: String
var text: String?
var location: Location?
var expires: Date?
var whatText: String?
var whereText: String?
var whenText: String?
var impactText: String?
var instructionsText: String?
var targetAreaText: String?
}
...@@ -29,9 +29,9 @@ public class BlendHealthSource: HealthSource { ...@@ -29,9 +29,9 @@ public class BlendHealthSource: HealthSource {
private static let blendAPIKeyHeaderName = "blend-api-key" private static let blendAPIKeyHeaderName = "blend-api-key"
private static let blendAPIKey = "0imfnc8mVLWwsAawjYr4Rx-Af50DDqtlx" private static let blendAPIKey = "0imfnc8mVLWwsAawjYr4Rx-Af50DDqtlx"
#if DEBUG #if DEBUG
public var healthUpdateInterval: TimeInterval = TimeInterval(2 * 60) // 2 minutes public let healthUpdateInterval = TimeInterval(2 * 60) // 2 minutes
#else #else
public var healthUpdateInterval: TimeInterval = TimeInterval(15 * 60) // 15 minutes public let healthUpdateInterval = TimeInterval(15 * 60) // 15 minutes
#endif #endif
/// This queue is needed to synchronize access to locationsBeingUpdated. Also, to make logging more clear. /// This queue is needed to synchronize access to locationsBeingUpdated. Also, to make logging more clear.
private let internalQueue: OperationQueue = { private let internalQueue: OperationQueue = {
......
//
// NWSAlert.swift
// 1Weather
//
// Created by Demid Merzlyakov on 14.04.2021.
//
import Foundation
public enum NWSSeverityLevel: String, Codable {
case warning = "1"
case watch = "2"
case advisory = "3"
}
public struct NWSAlert: Codable, Equatable, Hashable {
public let weatherID: String
public let messageID: String
public let messageURL: String
public let severityLevel: NWSSeverityLevel
public let description: String
public let expires: Date //expiresUTC="2012-09-25 01:00:00"
/// This is the contents of the messageURL. Fetched separately.
public var weatherMessage: String? = nil
/// This property is set by NWSAlertManager after decoding the response.
public var city: String = ""
// Used to encode/decode a JSON object to send/recieve data with the server
private enum CodingKeys: String, CodingKey {
case weatherID
case messageID
case severityLevel
case description
case expires = "expiresUTC"
case messageURL
}
public var expired: Bool {
return Date() < expires
}
// MARK: Equatable implementation
public static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.weatherID == rhs.weatherID &&
lhs.messageID == rhs.messageID &&
lhs.messageURL == rhs.messageURL &&
lhs.severityLevel == rhs.severityLevel &&
lhs.description == rhs.description &&
lhs.expires == rhs.expires
// do not compare weatherMessage & city
}
public func hash(into hasher: inout Hasher) {
hasher.combine(weatherID)
hasher.combine(messageID)
hasher.combine(messageURL)
hasher.combine(severityLevel)
hasher.combine(description)
hasher.combine(expires)
}
}
//
// NWSCurrentEventsReponse.swift
// 1Weather
//
// Created by Demid Merzlyakov on 15.04.2021.
//
import Foundation
struct NWSCurrentEventsReponse: Codable {
struct Wrapper: Codable {
let events: [NWSAlert]?
}
let status: String
let events: NWSCurrentEventsReponse.Wrapper?
}
//
// NWSAlertsManager.swift
// 1Weather
//
// Created by Demid Merzlyakov on 16.04.2021.
//
import Foundation
public enum NWSError: Error {
case insufficientLocationInfo
case alreadyBeingUpdated
case badUrl
case networkError(Error?)
case badServerResponse(Error?)
}
public class NWSAlertsManager {
public typealias Completion = ([NWSAlert]?, NWSError?) -> ()
#if DEBUG
public let updateInterval = TimeInterval(2 * 60) // 2 minutes
#else
public let updateInterval = TimeInterval(15 * 60) // 15 minutes
#endif
#if DEBUG
public var useStaging = true
#else
public var useStaging = false
#endif
private let baseUrlProduction = "https://nwsalert.onelouder.com"
private let baseUrlStaging = "https://sta-nwsalert.onelouder.com"
private let log = Logger(componentName: "NWSAlertsManager")
private var locationsBeingUpdated = Set<Location>()
private var baseUrl: String {
useStaging ? baseUrlStaging : baseUrlProduction
}
/// This queue is needed to synchronize access to locationsBeingUpdated and the alerts list. Also, to make logging more clear.
private let internalQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "WdtWeatherSource Queue"
queue.maxConcurrentOperationCount = 1
return queue
}()
private lazy var currentEventsDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
return dateFormatter
}()
public var alerts = [NWSAlert]()
public func fetchActiveAlerts(for location: Location, completion: @escaping NWSAlertsManager.Completion) {
internalQueue.addOperation { [weak self] in
let extendedCompletion: NWSAlertsManager.Completion = { [weak self] (updatedLocation, error) in
self?.internalQueue.addOperation {
completion(updatedLocation, error)
self?.locationsBeingUpdated.remove(location)
}
}
self?.fetchActiveAlertsInternal(for: location, completion: extendedCompletion)
}
}
private func fetchActiveAlertsInternal(for location: Location, completion: @escaping NWSAlertsManager.Completion) {
guard !locationsBeingUpdated.contains(location) else {
completion(nil, .alreadyBeingUpdated)
return
}
locationsBeingUpdated.insert(location)
log.info("Start update for \(location)")
var params: [String: String] = [
"format": "json"
]
if let fips_code = location.fipsCode {
params["weather_id"] = fips_code
}
else if let coordinates = location.coordinates{
params["geo"] = String(format: "%.5f,%.5f", coordinates.latitude, coordinates.longitude)
}
else {
completion(nil, .insufficientLocationInfo)
return
}
params["echoCity"] = location.cityName
guard var urlComponents = URLComponents(string: self.baseUrl.appending("/current_events")) else {
log.error("Couldn't create URLComponents from \(self.baseUrl)")
completion(nil, .badUrl)
fatalError("Should never happen. Couldn't create URL components from \(self.baseUrl). This URL has to always be correct.") // Should never happen, but a lot of stuff that should never happen happens from time to time, so let's at least handle it gracefully in prod.
return
}
urlComponents.queryItems = params.map { URLQueryItem(name: $0, value: $1) }
guard let url = urlComponents.url else {
log.error("Couldn't create URL with params: \(params)")
completion(nil, .badUrl)
return
}
let urlSession = URLSession.shared
let dataTask = urlSession.dataTask(with: url) { [weak self] (data, response, error) in
guard let self = self else { return }
guard let data = data else {
completion(nil, .networkError(error))
return
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(self.currentEventsDateFormatter)
do {
let response = try decoder.decode(NWSCurrentEventsReponse.self, from: data)
guard let events = response.events?.events else {
completion(nil, .badServerResponse(nil))
return
}
let eventsWithLocation: [NWSAlert] = events.map { (alert) -> NWSAlert in
var updated: NWSAlert = alert
updated.city = location.cityName ?? ""
return updated
}
self.merge(alerts: eventsWithLocation)
completion(eventsWithLocation, nil)
}
catch {
completion(nil, .badServerResponse(error))
}
}
dataTask.resume()
}
func merge(alerts newAlerts: [NWSAlert]) {
//TODO: optimize
var resultingSet = Set<NWSAlert>()
for alert in self.alerts {
if !alert.expired {
resultingSet.insert(alert)
}
}
for alert in newAlerts {
if !alert.expired {
resultingSet.insert(alert)
}
}
// TODO: maybe we need a more sophisticated logic for sorting: take severity into account, for instance.
let resultingAlerts = resultingSet.sorted(by: { $1.expires > $0.expires })
// sync, because we don't want another merge to strat before assignment to self.alerts completes.
// A deadlock shouldn't happen unless main thread waits on internalQueue, which it doesn't.
DispatchQueue.main.sync {
self.alerts = resultingAlerts
}
}
}
...@@ -68,7 +68,10 @@ public class WdtWeatherSource: WeatherSource { ...@@ -68,7 +68,10 @@ public class WdtWeatherSource: WeatherSource {
} }
guard var urlComponents = URLComponents(string: urlToUse) else { guard var urlComponents = URLComponents(string: urlToUse) else {
log.error("Couldn't create URLComponents from \(urlToUse)")
assertionFailure("Should never happen. The URL should be correct.") assertionFailure("Should never happen. The URL should be correct.")
// Should never happen, but a lot of stuff that should never happen happens from time to time, so let's at least handle it gracefully in prod.
completion(nil, WdtWeatherSourceError.badUrl)
return return
} }
...@@ -93,6 +96,7 @@ public class WdtWeatherSource: WeatherSource { ...@@ -93,6 +96,7 @@ public class WdtWeatherSource: WeatherSource {
urlComponents.queryItems = queryParameters.map { URLQueryItem(name: $0, value: $1) } urlComponents.queryItems = queryParameters.map { URLQueryItem(name: $0, value: $1) }
guard let url = urlComponents.url else { guard let url = urlComponents.url else {
log.error("Couldn't create URL with params: \(queryParameters)")
completion(nil, WdtWeatherSourceError.badUrl) completion(nil, WdtWeatherSourceError.badUrl)
return return
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment