Commit 5f336837c1
Verified · cmc
Layout: unified · split
App/Sources/AuroraAlerts.swift added +102
| @@ -0,0 +1,102 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | import SkyEngine | ||
| 4 | import UIKit | ||
| 5 | import UserNotifications | ||
| 6 | |||
| 7 | /// Registers this device with the backend for aurora pushes. Sends only the push | ||
| 8 | /// token, a 5° geomagnetic latitude band and the UTC offset. | ||
| 9 | @MainActor | ||
| 10 | @Observable | ||
| 11 | final class AuroraAlerts { | ||
| 12 | static let shared = AuroraAlerts() | ||
| 13 | |||
| 14 | private static let enabledKey = "auroraAlerts" | ||
| 15 | private static let tokenKey = "apnsToken" | ||
| 16 | |||
| 17 | private(set) var enabled = UserDefaults.standard.bool(forKey: AuroraAlerts.enabledKey) | ||
| 18 | private(set) var status: String? | ||
| 19 | private var observer: Observer? | ||
| 20 | |||
| 21 | #if DEBUG | ||
| 22 | private let environment = "sandbox" | ||
| 23 | #else | ||
| 24 | private let environment = "production" | ||
| 25 | #endif | ||
| 26 | |||
| 27 | private var token: String? { UserDefaults.standard.string(forKey: Self.tokenKey) } | ||
| 28 | |||
| 29 | /// Called by the app delegate whenever iOS hands over a device token. | ||
| 30 | func receivedToken(_ token: String) { | ||
| 31 | UserDefaults.standard.set(token, forKey: Self.tokenKey) | ||
| 32 | Task { await sync() } | ||
| 33 | } | ||
| 34 | |||
| 35 | /// Called when the sky's location changes, so the band stays current. | ||
| 36 | func update(observer: Observer) { | ||
| 37 | guard observer != self.observer else { return } | ||
| 38 | self.observer = observer | ||
| 39 | Task { await sync() } | ||
| 40 | } | ||
| 41 | |||
| 42 | func setEnabled(_ on: Bool) async { | ||
| 43 | if on { | ||
| 44 | let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false | ||
| 45 | guard granted else { | ||
| 46 | status = "Notifications are off for Moonrise. Turn them on in the Settings app." | ||
| 47 | return | ||
| 48 | } | ||
| 49 | UIApplication.shared.registerForRemoteNotifications() | ||
| 50 | } | ||
| 51 | enabled = on | ||
| 52 | UserDefaults.standard.set(on, forKey: Self.enabledKey) | ||
| 53 | await sync() | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Registers or unregisters to match `enabled`. | ||
| 57 | func sync() async { | ||
| 58 | guard let apiBase, let token else { return } | ||
| 59 | if enabled { | ||
| 60 | guard let observer else { return } | ||
| 61 | let body: [String: Any] = [ | ||
| 62 | "token": token, | ||
| 63 | "environment": environment, | ||
| 64 | "band": Geomagnetic.band(observer), | ||
| 65 | "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60, | ||
| 66 | "aurora": true, | ||
| 67 | ] | ||
| 68 | var req = URLRequest(url: apiBase.appending(path: "v1/devices")) | ||
| 69 | req.httpMethod = "POST" | ||
| 70 | req.setValue("application/json", forHTTPHeaderField: "content-type") | ||
| 71 | req.httpBody = try? JSONSerialization.data(withJSONObject: body) | ||
| 72 | let ok = await send(req) | ||
| 73 | status = ok ? nil : "Couldn't reach the Moonrise server. Alerts will register next time." | ||
| 74 | } else { | ||
| 75 | var req = URLRequest(url: apiBase.appending(path: "v1/devices/\(token)")) | ||
| 76 | req.httpMethod = "DELETE" | ||
| 77 | _ = await send(req) | ||
| 78 | status = nil | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | func sendTest() async { | ||
| 83 | guard let apiBase, let token else { status = "No device token yet."; return } | ||
| 84 | var req = URLRequest(url: apiBase.appending(path: "v1/devices/\(token)/test")) | ||
| 85 | req.httpMethod = "POST" | ||
| 86 | guard let (_, response) = try? await URLSession.shared.data(for: req), let http = response as? HTTPURLResponse else { | ||
| 87 | status = "Couldn't reach the Moonrise server." | ||
| 88 | return | ||
| 89 | } | ||
| 90 | status = switch http.statusCode { | ||
| 91 | case 200: "Test alert sent." | ||
| 92 | case 429: "A test was sent recently. Try again in a few minutes." | ||
| 93 | case 404: "This device isn't registered yet." | ||
| 94 | default: "The test alert failed (\(http.statusCode))." | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | private func send(_ req: URLRequest) async -> Bool { | ||
| 99 | guard let (_, response) = try? await URLSession.shared.data(for: req) else { return false } | ||
| 100 | return (response as? HTTPURLResponse)?.statusCode == 200 | ||
| 101 | } | ||
| 102 | } | ||
App/Sources/Info.plist +16
| @@ -2,6 +2,22 @@ | |||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 3 | <plist version="1.0"> | 3 | <plist version="1.0"> |
| 4 | <dict> | 4 | <dict> |
| 5 | <key>CFBundleDevelopmentRegion</key> | ||
| 6 | <string>$(DEVELOPMENT_LANGUAGE)</string> | ||
| 7 | <key>CFBundleExecutable</key> | ||
| 8 | <string>$(EXECUTABLE_NAME)</string> | ||
| 9 | <key>CFBundleIdentifier</key> | ||
| 10 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||
| 11 | <key>CFBundleInfoDictionaryVersion</key> | ||
| 12 | <string>6.0</string> | ||
| 13 | <key>CFBundleName</key> | ||
| 14 | <string>$(PRODUCT_NAME)</string> | ||
| 15 | <key>CFBundlePackageType</key> | ||
| 16 | <string>APPL</string> | ||
| 17 | <key>CFBundleShortVersionString</key> | ||
| 18 | <string>1.0</string> | ||
| 19 | <key>CFBundleVersion</key> | ||
| 20 | <string>1</string> | ||
| 5 | <key>MoonriseAPIURL</key> | 21 | <key>MoonriseAPIURL</key> |
| 6 | <string>$(MOONRISE_API_URL)</string> | 22 | <string>$(MOONRISE_API_URL)</string> |
| 7 | <key>NSAppTransportSecurity</key> | 23 | <key>NSAppTransportSecurity</key> |
App/Sources/MoonriseApp.swift +9 −1
| @@ -1,5 +1,6 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UIKit | 2 | import UIKit |
| 3 | import UserNotifications | ||
| 3 | import os | 4 | import os |
| 4 | 5 | ||
| 5 | let log = Logger(subsystem: "sh.krz.moonrise", category: "app") | 6 | let log = Logger(subsystem: "sh.krz.moonrise", category: "app") |
| @@ -30,8 +31,9 @@ struct MoonriseApp: App { | |||
| 30 | } | 31 | } |
| 31 | } | 32 | } |
| 32 | 33 | ||
| 33 | final class AppDelegate: NSObject, UIApplicationDelegate { | 34 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| 34 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { | 35 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| 36 | UNUserNotificationCenter.current().delegate = self | ||
| 35 | application.registerForRemoteNotifications() | 37 | application.registerForRemoteNotifications() |
| 36 | return true | 38 | return true |
| 37 | } | 39 | } |
| @@ -39,6 +41,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate { | |||
| 39 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { | 41 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { |
| 40 | let token = deviceToken.map { String(format: "%02x", $0) }.joined() | 42 | let token = deviceToken.map { String(format: "%02x", $0) }.joined() |
| 41 | log.info("APNs device token: \(token, privacy: .public)") | 43 | log.info("APNs device token: \(token, privacy: .public)") |
| 44 | Task { @MainActor in AuroraAlerts.shared.receivedToken(token) } | ||
| 45 | } | ||
| 46 | |||
| 47 | /// Show alerts as banners while the app is open. | ||
| 48 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { | ||
| 49 | [.banner, .sound] | ||
| 42 | } | 50 | } |
| 43 | 51 | ||
| 44 | func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { | 52 | func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { |
App/Sources/OnboardingScreen.swift +1 −1
| @@ -20,7 +20,7 @@ struct OnboardingScreen: View { | |||
| 20 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24) | 20 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24) |
| 21 | VStack(alignment: .leading, spacing: 14) { | 21 | VStack(alignment: .leading, spacing: 14) { |
| 22 | Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.") | 22 | Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.") |
| 23 | Text("To draw the sky for where you are, Moonrise uses your location. It is used on this phone to work out positions and is not sent anywhere.") | 23 | Text("To draw the sky for where you are, Moonrise uses your location. It is used on this phone to work out positions and is not sent to us or anyone else.") |
| 24 | .foregroundStyle(ink.opacity(0.75)) | 24 | .foregroundStyle(ink.opacity(0.75)) |
| 25 | } | 25 | } |
| 26 | .font(.body) | 26 | .font(.body) |
App/Sources/SettingsScreen.swift +17
| @@ -58,6 +58,7 @@ struct SettingsScreen: View { | |||
| 58 | @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 | 58 | @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 |
| 59 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" | 59 | @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" |
| 60 | @Environment(\.dismiss) private var dismiss | 60 | @Environment(\.dismiss) private var dismiss |
| 61 | @State private var alerts = AuroraAlerts.shared | ||
| 61 | var onChange: () -> Void | 62 | var onChange: () -> Void |
| 62 | 63 | ||
| 63 | var body: some View { | 64 | var body: some View { |
| @@ -74,6 +75,22 @@ struct SettingsScreen: View { | |||
| 74 | } | 75 | } |
| 75 | } | 76 | } |
| 76 | } | 77 | } |
| 78 | Section { | ||
| 79 | Toggle("Aurora alerts", isOn: Binding( | ||
| 80 | get: { alerts.enabled }, | ||
| 81 | set: { on in Task { await alerts.setEnabled(on) } } | ||
| 82 | )) | ||
| 83 | if alerts.enabled { | ||
| 84 | Button("Send a test alert") { Task { await alerts.sendTest() } } | ||
| 85 | } | ||
| 86 | if let status = alerts.status { | ||
| 87 | Text(status).font(.footnote).foregroundStyle(.secondary) | ||
| 88 | } | ||
| 89 | } header: { | ||
| 90 | Text("Alerts") | ||
| 91 | } footer: { | ||
| 92 | Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.") | ||
| 93 | } | ||
| 77 | Section { | 94 | Section { |
| 78 | Picker("Night mode", selection: $nightMode) { | 95 | Picker("Night mode", selection: $nightMode) { |
| 79 | Text("Off").tag("off") | 96 | Text("Off").tag("off") |
App/Sources/SkyModel.swift +1
| @@ -91,6 +91,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | |||
| 91 | /// Rebuilds the scene for the displayed time. Requests made during a build | 91 | /// Rebuilds the scene for the displayed time. Requests made during a build |
| 92 | /// collapse into one rebuild when it finishes. | 92 | /// collapse into one rebuild when it finishes. |
| 93 | func refresh() { | 93 | func refresh() { |
| 94 | AuroraAlerts.shared.update(observer: observer) | ||
| 94 | guard !building else { pending = true; return } | 95 | guard !building else { pending = true; return } |
| 95 | building = true | 96 | building = true |
| 96 | let date = displayedDate, observer = observer, astronomer = astronomer | 97 | let date = displayedDate, observer = observer, astronomer = astronomer |
Packages/MoonriseKit/Sources/SkyEngine/Geomagnetic.swift added +24
| @@ -0,0 +1,24 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Geomagnetic latitude from a centred-dipole model of Earth's field. | ||
| 4 | public enum Geomagnetic { | ||
| 5 | /// North geomagnetic pole, IGRF-14 for 2025 (approximately 80.8° N, 72.8° W). | ||
| 6 | public static let poleLatitude = 80.8 | ||
| 7 | public static let poleLongitude = -72.8 | ||
| 8 | |||
| 9 | /// Geomagnetic latitude in degrees for a geographic position. | ||
| 10 | public static func latitude(_ observer: Observer) -> Double { | ||
| 11 | let r = Double.pi / 180 | ||
| 12 | let s = sin(observer.latitude * r) * sin(poleLatitude * r) | ||
| 13 | + cos(observer.latitude * r) * cos(poleLatitude * r) * cos((observer.longitude - poleLongitude) * r) | ||
| 14 | return asin(max(-1, min(1, s))) / r | ||
| 15 | } | ||
| 16 | |||
| 17 | /// The backend's 5° band, named by its equatorward edge: 50 covers 50–55° N, -55 covers 50–55° S. | ||
| 18 | /// Must match `bandFor` in server/src/aurora.ts. | ||
| 19 | public static func band(_ observer: Observer) -> Int { | ||
| 20 | let m = latitude(observer) | ||
| 21 | let b = Int((abs(m) / 5).rounded(.down)) * 5 | ||
| 22 | return m < 0 ? -(b + 5) : b | ||
| 23 | } | ||
| 24 | } | ||
Packages/MoonriseKit/Tests/SkyEngineTests/GeomagneticTests.swift added +21
| @@ -0,0 +1,21 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import SkyEngine | ||
| 3 | |||
| 4 | @Test func chicagoIsAboutFiftyOneDegreesGeomagnetic() { | ||
| 5 | let m = Geomagnetic.latitude(chicago) | ||
| 6 | #expect(m > 49 && m < 53, "\(m)") | ||
| 7 | #expect(Geomagnetic.band(chicago) == 50) | ||
| 8 | } | ||
| 9 | |||
| 10 | @Test func southernBandsAreNegative() { | ||
| 11 | // Hobart, Tasmania: the dipole model gives about 49.6° S, so the 45–50° S band. | ||
| 12 | let hobart = Observer(latitude: -42.88, longitude: 147.33) | ||
| 13 | let m = Geomagnetic.latitude(hobart) | ||
| 14 | #expect(m < -45 && m > -55, "\(m)") | ||
| 15 | #expect(Geomagnetic.band(hobart) == -50) | ||
| 16 | } | ||
| 17 | |||
| 18 | @Test func poleIsNinety() { | ||
| 19 | let pole = Observer(latitude: Geomagnetic.poleLatitude, longitude: Geomagnetic.poleLongitude) | ||
| 20 | #expect(abs(Geomagnetic.latitude(pole) - 90) < 1e-6) | ||
| 21 | } | ||
docs/app-store.org +9 −8
| @@ -11,17 +11,18 @@ | |||
| 11 | | Privacy policy URL | https://moonrise-server.audit-labs.workers.dev/privacy | | 11 | | Privacy policy URL | https://moonrise-server.audit-labs.workers.dev/privacy | |
| 12 | 12 | ||
| 13 | * App Privacy ("nutrition label") | 13 | * App Privacy ("nutrition label") |
| 14 | Answer: *Data Not Collected*. | 14 | Aurora alerts (opt-in) send the push token, a 5° geomagnetic latitude |
| 15 | band and the UTC offset to the backend, so the answer is *Data Collected*: | ||
| 15 | 16 | ||
| 16 | - Location: used on device only; never sent. Apple's definition of | 17 | | Data type | Collected | Linked to identity | Tracking | Purpose | |
| 17 | "collect" is transmitting data off the device, so it is not collected. | 18 | |- |
| 18 | - Network requests (backend, NOAA, NASA, image hosts) carry no | 19 | | Coarse Location | Yes (5° geomagnetic band) | No | No | App Functionality | |
| 20 | | Device ID | Yes (APNs push token) | No | No | App Functionality | | ||
| 21 | |||
| 22 | - Precise location: used on device only, never sent. | ||
| 23 | - Other network requests (backend, NOAA, NASA, image hosts) carry no | ||
| 19 | identifiers. | 24 | identifiers. |
| 20 | - No analytics, advertising, accounts, crash reporting SDKs or tracking. | 25 | - No analytics, advertising, accounts, crash reporting SDKs or tracking. |
| 21 | - Revisit when aurora alerts ship: the push token, a 5° geomagnetic | ||
| 22 | latitude band and UTC offset are sent to the backend. That is | ||
| 23 | likely "Coarse Location" and "Device ID", used for "App | ||
| 24 | Functionality", not linked to identity, not used for tracking. | ||
| 25 | 26 | ||
| 26 | * Export compliance | 27 | * Export compliance |
| 27 | =ITSAppUsesNonExemptEncryption = NO= (HTTPS only), set in =project.yml=. | 28 | =ITSAppUsesNonExemptEncryption = NO= (HTTPS only), set in =project.yml=. |
server/src/index.ts +19 −1
| @@ -65,10 +65,13 @@ export default { | |||
| 65 | await env.DB.prepare( | 65 | await env.DB.prepare( |
| 66 | `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6) | 66 | `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6) |
| 67 | ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`, | 67 | ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`, |
| 68 | ).bind(d.token, d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run(); | 68 | ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run(); |
| 69 | return json({ ok: true }); | 69 | return json({ ok: true }); |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | const test = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})\/test$/i); | ||
| 73 | if (request.method === "POST" && test) return sendTest(env, test[1].toLowerCase(), now); | ||
| 74 | |||
| 72 | const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i); | 75 | const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i); |
| 73 | if (request.method === "DELETE" && del) { | 76 | if (request.method === "DELETE" && del) { |
| 74 | await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run(); | 77 | await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run(); |
| @@ -86,6 +89,21 @@ export default { | |||
| 86 | }, | 89 | }, |
| 87 | } satisfies ExportedHandler<Env>; | 90 | } satisfies ExportedHandler<Env>; |
| 88 | 91 | ||
| 92 | /** One test push to a registered device, at most once per 10 minutes per device. */ | ||
| 93 | async function sendTest(env: Env, token: string, now: Date): Promise<Response> { | ||
| 94 | const device = await env.DB.prepare("SELECT environment FROM devices WHERE token = ?1").bind(token).first<{ environment: string }>(); | ||
| 95 | if (!device) return json({ error: "not registered" }, 404); | ||
| 96 | const window = `${token}:${now.toISOString().slice(0, 15)}`; // 10-minute bucket | ||
| 97 | const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('test', 0, ?1, ?2)") | ||
| 98 | .bind(window, now.toISOString()).run(); | ||
| 99 | if (!claimed.meta.changes) return json({ error: "try again in a few minutes" }, 429); | ||
| 100 | const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC }; | ||
| 101 | const payload = { aps: { alert: { title: "Moonrise test alert", body: "Aurora alerts reach this device." }, sound: "default" } }; | ||
| 102 | const r = await send(config, token, payload, device.environment === "sandbox"); | ||
| 103 | if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(token).run(); | ||
| 104 | return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502); | ||
| 105 | } | ||
| 106 | |||
| 89 | async function launches(env: Env, now: Date): Promise<Response> { | 107 | async function launches(env: Env, now: Date): Promise<Response> { |
| 90 | const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); | 108 | const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); |
| 91 | const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); | 109 | const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); |
server/src/privacy.ts +12 −3
| @@ -20,7 +20,7 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli | |||
| 20 | <p>Last updated 18 October 2026.</p> | 20 | <p>Last updated 18 October 2026.</p> |
| 21 | 21 | ||
| 22 | <h2>Summary</h2> | 22 | <h2>Summary</h2> |
| 23 | <p>Moonrise has no accounts, no advertising, no analytics and no tracking. It does not collect personal data.</p> | 23 | <p>Moonrise has no accounts, no advertising, no analytics and no tracking. If you turn on aurora alerts, it sends our server three things needed to deliver them, described below. Nothing else is collected.</p> |
| 24 | 24 | ||
| 25 | <h2>Location</h2> | 25 | <h2>Location</h2> |
| 26 | <p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p> | 26 | <p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p> |
| @@ -35,8 +35,17 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli | |||
| 35 | </ul> | 35 | </ul> |
| 36 | <p>These requests contain no identifiers. As with any internet request, the servers receive your IP address. Cloudflare may keep short-lived logs; see Cloudflare's privacy policy.</p> | 36 | <p>These requests contain no identifiers. As with any internet request, the servers receive your IP address. Cloudflare may keep short-lived logs; see Cloudflare's privacy policy.</p> |
| 37 | 37 | ||
| 38 | <h2>Notifications and Live Activities</h2> | 38 | <h2>Aurora alerts</h2> |
| 39 | <p>A launch countdown you add to the Lock Screen runs on your device. The app does not currently register your device with our server.</p> | 39 | <p>Aurora alerts are off until you turn them on in Settings. When they are on, the app sends our server:</p> |
| 40 | <ul> | ||
| 41 | <li>this device's push notification token, which Apple issues so a server can send it notifications;</li> | ||
| 42 | <li>a 5° band of geomagnetic latitude worked out from your location on the device (for example "50° to 55° north"), not your location itself;</li> | ||
| 43 | <li>your time zone offset from UTC, so alerts arrive at night.</li> | ||
| 44 | </ul> | ||
| 45 | <p>These are stored with the time the device last checked in, and used only to send aurora alerts. They are not linked to your identity or shared. Turning alerts off deletes them from the server. A token that Apple reports as no longer valid is deleted automatically.</p> | ||
| 46 | |||
| 47 | <h2>Live Activities</h2> | ||
| 48 | <p>A launch countdown you add to the Lock Screen runs on your device and sends nothing to our server.</p> | ||
| 40 | 49 | ||
| 41 | <h2>Changes</h2> | 50 | <h2>Changes</h2> |
| 42 | <p>If this policy changes, the new version will be posted here with a new date.</p> | 51 | <p>If this policy changes, the new version will be posted here with a new date.</p> |