App/Sources/AuroraAlerts.swift
116 lines · 4372 bytes
1import Foundation
2import Observation
3import SkyEngine
4import UIKit
5import UserNotifications
6
7/// Registers this device with the backend for aurora and news pushes. Sends only the push
8/// token, a 5° geomagnetic latitude band, the UTC offset and which alerts are on.
9@MainActor
10@Observable
11final class AuroraAlerts {
12 static let shared = AuroraAlerts()
13
14 private static let enabledKey = "auroraAlerts"
15 private static let newsKey = "newsAlerts"
16 private static let tokenKey = "apnsToken"
17
18 private(set) var enabled = UserDefaults.standard.bool(forKey: AuroraAlerts.enabledKey)
19 /// The daily news digest at 09:00.
20 private(set) var news = UserDefaults.standard.bool(forKey: AuroraAlerts.newsKey)
21 private(set) var status: String?
22 private var observer: Observer?
23
24 #if DEBUG
25 private let environment = "sandbox"
26 #else
27 private let environment = "production"
28 #endif
29
30 private var token: String? { UserDefaults.standard.string(forKey: Self.tokenKey) }
31
32 /// Called by the app delegate whenever iOS hands over a device token.
33 func receivedToken(_ token: String) {
34 UserDefaults.standard.set(token, forKey: Self.tokenKey)
35 Task { await sync() }
36 }
37
38 /// Called when the sky's location changes, so the band stays current.
39 func update(observer: Observer) {
40 guard observer != self.observer else { return }
41 self.observer = observer
42 Task { await sync() }
43 }
44
45 func setEnabled(_ on: Bool) async {
46 if on, !(await permitted()) { return }
47 enabled = on
48 UserDefaults.standard.set(on, forKey: Self.enabledKey)
49 await sync()
50 }
51
52 func setNews(_ on: Bool) async {
53 if on, !(await permitted()) { return }
54 news = on
55 UserDefaults.standard.set(on, forKey: Self.newsKey)
56 await sync()
57 }
58
59 private func permitted() async -> Bool {
60 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
61 if granted {
62 UIApplication.shared.registerForRemoteNotifications()
63 } else {
64 status = "Notifications are off for Moonrise. Turn them on in the Settings app."
65 }
66 return granted
67 }
68
69 /// Registers or unregisters to match `enabled`.
70 func sync() async {
71 guard let apiBase, let token else { return }
72 if enabled || news {
73 guard let observer else { return }
74 let body: [String: Any] = [
75 "token": token,
76 "environment": environment,
77 "band": Geomagnetic.band(observer),
78 "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60,
79 "aurora": enabled,
80 "news": news,
81 ]
82 var req = URLRequest(url: apiBase.appending(path: "v1/devices"))
83 req.httpMethod = "POST"
84 req.setValue("application/json", forHTTPHeaderField: "content-type")
85 req.httpBody = try? JSONSerialization.data(withJSONObject: body)
86 let ok = await send(req)
87 status = ok ? nil : "Couldn't reach the Moonrise server. Alerts will register next time."
88 } else {
89 var req = URLRequest(url: apiBase.appending(path: "v1/devices/\(token)"))
90 req.httpMethod = "DELETE"
91 _ = await send(req)
92 status = nil
93 }
94 }
95
96 func sendTest() async {
97 guard let apiBase, let token else { status = "No device token yet."; return }
98 var req = URLRequest(url: apiBase.appending(path: "v1/devices/\(token)/test"))
99 req.httpMethod = "POST"
100 guard let (_, response) = try? await URLSession.shared.data(for: req), let http = response as? HTTPURLResponse else {
101 status = "Couldn't reach the Moonrise server."
102 return
103 }
104 status = switch http.statusCode {
105 case 200: "Test alert sent."
106 case 429: "A test was sent recently. Try again in a few minutes."
107 case 404: "This device isn't registered yet."
108 default: "The test alert failed (\(http.statusCode))."
109 }
110 }
111
112 private func send(_ req: URLRequest) async -> Bool {
113 guard let (_, response) = try? await URLSession.shared.data(for: req) else { return false }
114 return (response as? HTTPURLResponse)?.statusCode == 200
115 }
116}