// // 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 "" } } }