krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/AuroraAlerts.swift

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/App/Sources/AuroraAlerts.swift history · blame · raw

116 lines · 4372 bytes

9 symbols in this file
  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}