368 lines
14 KiB
Swift
368 lines
14 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
|
|
import UIKit
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 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
|
|
let intensita = peak * exp(-distanceKm/peak/250)
|
|
let stringSuffix: String
|
|
|
|
if intensita < 0.004 {
|
|
stringSuffix = "no_shaking"
|
|
} else if intensita < 0.30 {
|
|
stringSuffix = "mild"
|
|
} else if intensita < 0.70 {
|
|
stringSuffix = "moderate"
|
|
} else {
|
|
stringSuffix = "strong"
|
|
}
|
|
bestAttemptContent.body = "alert_intensity_\(stringSuffix)".localized
|
|
|
|
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":
|
|
let provider = userInfo.string(forKey: "provider", orDefault: "")
|
|
let intensity = userInfo.double(forKey: "magnitude", orDefault: 0)
|
|
|
|
let color: String
|
|
if intensity < 2.0 {
|
|
color = "_white"
|
|
} else if intensity < 3.5 {
|
|
color = "_green"
|
|
} else if intensity < 4.5 {
|
|
color = "_yellow"
|
|
} else if intensity < 5.5 {
|
|
color = "_red"
|
|
} else {
|
|
color = "_purple"
|
|
}
|
|
|
|
iconName = manualIconName(for: provider, color: color)
|
|
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 ]
|
|
// }
|
|
// }
|
|
|
|
do {
|
|
let image = createImage(for: "M2.3")
|
|
let imageData = try NotificationServiceImageDownloader.shared.storeImage(image)
|
|
let imageAttachment = try UNNotificationAttachment(
|
|
identifier: "image",
|
|
url: imageData.localUrl,
|
|
options: nil)
|
|
bestAttemptContent.attachments = [imageAttachment]
|
|
bestAttemptContent.title = bestAttemptContent.title + " [IMG]"
|
|
} catch {
|
|
bestAttemptContent.title = bestAttemptContent.title + " [ROTTO]"
|
|
}
|
|
|
|
// 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 remved
|
|
// ref: https://stackoverflow.com/questions/53697279/why-are-notifications-not-removed-with-removedeliverednotifications
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
completion()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
}
|
|
|
|
private func createImage(
|
|
for magnitude: String
|
|
) -> UIImage {
|
|
let frame = CGRect(x: 0, y: 0, width: 200, height: 200)
|
|
let renderer = UIGraphicsImageRenderer(size: frame.size)
|
|
let image = renderer.image { ctx in
|
|
ctx.cgContext.setFillColor(UIColor.white.cgColor)
|
|
ctx.cgContext.setStrokeColor(UIColor.black.cgColor) // dark
|
|
ctx.cgContext.setLineWidth(1.0)
|
|
|
|
let rectXPadding: CGFloat = 5.0
|
|
let rectYPadding: CGFloat = 40.0
|
|
let rectWidth = frame.width - 2*rectXPadding
|
|
let rectHeight = frame.height - 2*rectYPadding
|
|
let rectFrame = CGRect(x: rectXPadding, y: rectYPadding, width: rectWidth, height: rectHeight)
|
|
ctx.cgContext.addRect(rectFrame)
|
|
ctx.cgContext.drawPath(using: .fillStroke)
|
|
|
|
// text
|
|
let paragraphStyle = NSMutableParagraphStyle()
|
|
paragraphStyle.alignment = .center
|
|
let attrs = [
|
|
NSAttributedString.Key.font: UIFont.systemFont(ofSize: 60.0, weight: .bold),
|
|
NSAttributedString.Key.paragraphStyle: paragraphStyle,
|
|
NSAttributedString.Key.foregroundColor: UIColor.blue
|
|
]
|
|
|
|
ctx.cgContext.setStrokeColor(UIColor.blue.cgColor)
|
|
|
|
let textMargin: CGFloat = 5.0
|
|
let textWidth = rectFrame.width - 2*textMargin
|
|
let textHeight: CGFloat = 60.0
|
|
let xPos = rectFrame.minX + textMargin
|
|
let yPos = rectFrame.minY + (rectFrame.height - textHeight) / 2.0
|
|
let textFrame = CGRect(x: xPos, y: yPos, width: textWidth, height: textHeight)
|
|
magnitude.draw(with: textFrame, options: .usesLineFragmentOrigin, attributes: attrs, context: nil)
|
|
}
|
|
return image
|
|
}
|
|
}
|
|
|
|
|
|
class NotificationServiceImageDownloader {
|
|
|
|
enum DownloadError: Error {
|
|
case invalidUrl
|
|
case emptyData
|
|
case invalidImage
|
|
}
|
|
|
|
struct Image {
|
|
let data: Data
|
|
let png: UIImage
|
|
let localUrl: URL
|
|
}
|
|
|
|
|
|
static let shared = NotificationServiceImageDownloader()
|
|
|
|
// MARK: - Public
|
|
|
|
func downloadAndStoreImage(
|
|
for stringUrl: String
|
|
) async throws -> Image {
|
|
guard let url = URL(string: stringUrl) else {
|
|
throw DownloadError.invalidUrl
|
|
}
|
|
return try await downloadAndStoreImage(for: url)
|
|
}
|
|
|
|
func downloadAndStoreImage(
|
|
for url: URL
|
|
) async throws -> Image {
|
|
// download image from URL
|
|
let (data, _) = try await URLSession.shared.data(from: url)
|
|
guard let image = UIImage(data: data) else {
|
|
throw DownloadError.invalidImage
|
|
}
|
|
|
|
let identifier = "\(UUID().uuidString).\(url.pathExtension)"
|
|
let localUrl = try saveImageLocally(image: image, with: identifier)
|
|
return .init(data: data, png: image, localUrl: localUrl)
|
|
|
|
}
|
|
|
|
func storeImage(
|
|
_ image: UIImage
|
|
) throws -> Image {
|
|
let identifier = "\(UUID().uuidString).png"
|
|
let localUrl = try saveImageLocally(image: image, with: identifier)
|
|
return .init(data: Data(), png: image, localUrl: localUrl)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func saveImageLocally(
|
|
image: UIImage,
|
|
with identifier: String
|
|
) throws -> URL {
|
|
guard let imageData = image.pngData() else {
|
|
throw DownloadError.invalidImage
|
|
}
|
|
// create a subfolder in Caches directory and
|
|
// store the given image
|
|
let cacheDirectory = try FileManager.default.createSubfolder(in: .cachesDirectory, name: "NotificationImages")
|
|
let fileURL = cacheDirectory.appendingPathComponent(identifier)
|
|
|
|
try imageData.write(to: fileURL)
|
|
return fileURL
|
|
}
|
|
}
|
|
|
|
|
|
public extension FileManager {
|
|
|
|
/// Check if a subfolder already exists in a search path directory. If the folder does not exists, it will be created.
|
|
/// - Parameters:
|
|
/// - directory: The search path directory
|
|
/// - name: Name of the subfolder
|
|
/// - Returns: URL that specifies the directory
|
|
func createSubfolder(in directory: FileManager.SearchPathDirectory, name: String) throws -> URL {
|
|
let searchPathDirectory = urls(for: directory, in: .userDomainMask).first!
|
|
return try createSubfolder(at: searchPathDirectory, name: name)
|
|
}
|
|
|
|
/// Check if a subfolder already exists at a given URL. If the folder does not exists, it will be created.
|
|
/// - Parameters:
|
|
/// - directory: The search path directory
|
|
/// - name: Name of the subfolder
|
|
/// - Returns: URL that specifies the directory
|
|
func createSubfolder(at url: URL, name: String) throws -> URL {
|
|
let directoryUrl = url.appendingPathComponent(name)
|
|
|
|
var isDirectory: ObjCBool = false
|
|
if !fileExists(atPath: directoryUrl.path, isDirectory: &isDirectory) {
|
|
try createDirectory(at: directoryUrl, withIntermediateDirectories: false)
|
|
}
|
|
|
|
return directoryUrl
|
|
}
|
|
}
|