Files
eqn.ios/Sources/EQNNotificationService/NotificationService.swift
T

220 lines
8.9 KiB
Swift

//
// NotificationService.swift
// EQNNotificationService
//
// Created by Andrea Busi on 24/11/22.
// Copyright © 2022 Earthquake Network. All rights reserved.
//
import UserNotifications
import CoreLocation
import Shogun
class NotificationService: UNNotificationServiceExtension {
private static let EQNSoundNotification = UNNotificationSoundName("alert_sound.wav")
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent = bestAttemptContent else {
print("[NotificationService] Unable to get notification content")
contentComplete()
return
}
let userInfo = request.content.userInfo
guard let notificationType = userInfo.string(forKey: "type") else {
print("[NotificationService] Unable to get notification type")
contentComplete()
return
}
var iconName = ""
switch notificationType.lowercased() {
case "eqn":
// check if user has enabled critical alerts
let criticalAlertsEnabled = UserDefaults.appGroup?.bool(forKey: UserDefaults.AllertaSismicaCriticalAlerts) ?? false
// !!WORKAROUND
// this is a workaround to use critical alerts with legacy FCM api
// when the server implementation will be migrated to Firebase v1 APIs, this could be removed
let isCritical = userInfo.integer(forKey: "critical", orDefault: 0) == 1
if isCritical && criticalAlertsEnabled {
bestAttemptContent.sound = UNNotificationSound.criticalSoundNamed(Self.EQNSoundNotification)
} else {
bestAttemptContent.sound = UNNotificationSound(named: Self.EQNSoundNotification)
}
// evaluate intensity and get proper string to display
guard let latitude = userInfo.double(forKey: "latitude"),
let longitude = userInfo.double(forKey: "longitude"),
let peak = userInfo.double(forKey: "peak") else {
print("[NotificationService] Unable to get base info for intensity calculation")
return
}
let magnitude = userInfo.double(forKey: "mag") ?? 0
let location = CLLocation(latitude: latitude, longitude: longitude)
guard let distance = EQNUserData.shared.lastLocation?.distance(from: location) else {
print("[NotificationService] Unable to calculate distance or get last location")
return
}
let distanceKm = distance / 1_000
// If the shake is mild, user can disale sound and use default notification sound
// This logic is done here and not with the rest because distance and other informations are needed
let mildQuakeSoundDisabled = UserDefaults.appGroup?.bool(forKey: UserDefaults.AllertaSismicaSuonoDisabilitatoSismaDebole) ?? true
let isMildQuake = isMildQuake(magnitude: magnitude, distance: distanceKm)
if isMildQuake && mildQuakeSoundDisabled {
bestAttemptContent.sound = UNNotificationSound.default
}
let intensita = peak * exp(-distanceKm/peak/250)
let stringSuffix = if intensita < 0.004 {
"no_shaking"
} else if intensita < 0.30 {
"mild"
} else if intensita < 0.70 {
"moderate"
} else {
"strong"
}
bestAttemptContent.body = "alert_intensity_\(stringSuffix)".localized
let intensity = userInfo.integer(forKey: "intensity")
switch intensity {
case 0:
iconName = "star_realtime_white.png"
case 1:
iconName = "star_realtime_lightblue.png"
case 2:
iconName = "star_realtime_blue.png"
default:
break
}
case "manual":
// there are 12 levels, so a customized icon doesn't make sense
// use a generic warning icon instead
iconName = "warning_yellow.png"
case "official":
// don't show any images
break
default:
break
}
// add the icon as notification attachment
if !iconName.isEmpty {
iconName = iconName.replacingOccurrences(of: ".png", with: "")
if let imageUrl = Bundle(for: NotificationService.self).url(forResource: iconName, withExtension: "png"),
let attachment = try? UNNotificationAttachment(identifier: iconName, url: imageUrl) {
bestAttemptContent.attachments = [ attachment ]
}
}
// remove same type posted notification
removeNotifications(for: notificationType) { [weak self] in
self?.contentComplete()
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
// MARK: - Private
private func contentComplete() {
if let bestAttemptContent {
contentHandler?(bestAttemptContent)
}
}
private func removeNotifications(
for type: String?,
completion: @escaping() -> Void
) {
guard let type = type else {
completion()
return
}
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.getDeliveredNotifications { notifications in
let sameTypeNotifications = notifications.filter { notification in
let payload = notification.request.content.userInfo
if let notificationType = payload["type"] as? String {
return notificationType == type
}
return false
}
let identifiers = sameTypeNotifications.map { $0.request.identifier }
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
// !! Note: this is a known issue/bug
// we need to add a delay before invoking the completion, otherwise the notification will not be removed
// ref: https://stackoverflow.com/questions/53697279/why-are-notifications-not-removed-with-removedeliverednotifications
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
completion()
}
}
}
private func isMildQuake(
magnitude: Double,
distance: Double
) -> Bool {
var intensity_at_location: Double = 0
if distance > 0 {
let R: Double = 6371
let eq_depth: Double = 10.0
let hyp_distance = sqrt(pow(eq_depth, 2) + 4 * R * (R - eq_depth) * pow(sin(distance / (2 * R)), 2))
intensity_at_location = -2.15 * log10(hyp_distance) + 1.0 * magnitude + 2.31
}
return intensity_at_location < 3
}
// MARK: - Helpers
private func manualIconName(for provider: String, color: String) -> String {
switch provider.uppercased() {
case "USGS": return "star\(color).png"
case "SGC": return "star3\(color).png"
case "CSN": return "star3f\(color).png"
case "SSN": return "star4\(color).png"
case "INPRES": return "star4r\(color).png"
case "FUNVISIS": return "star6\(color).png"
case "INETER": return "triangle\(color).png"
case "RSN": return "triangle2\(color).png"
case "PHIVOLCS": return "triround_inner\(color).png"
case "IGEPN": return "triround\(color).png"
case "INGV": return "circle\(color).png"
case "EMSC": return "dyamond\(color).png"
case "IGP": return "dyamond_round\(color).png"
case "JMA": return "esa\(color).png"
case "GEONET": return "oct\(color).png"
case "CSI": return "penta\(color).png"
case "IGN": return "square\(color).png"
case "UASD", "BDTIM", "NCS": return "thick_star\(color).png"
case "RSPR": return "star6f\(color).png"
case "UOA": return "triangle\(color).png"
default: return ""
}
}
}