// // AdminAlertWatcher.swift // FTStore Admin // // The four things worth interrupting an operator for: storage filling up, // somebody posting feedback, somebody asking for a tweak, and a device getting // into a private server. // // Local notifications, not push. This app ships no credential and talks to one // server the operator configured; a push pipeline would mean a third party // holding a device token for an app whose whole point is that a decrypted IPA // is a login form and nothing else. // // The cost of that choice is honest and worth stating: a local notification can // only be posted while this app is running, which is when it is open and when // iOS grants it a background refresh. Background refresh is opportunistic — the // system decides, and on a device that rarely opens FTStore Admin it may be // hours. This is a nudge, not a pager. // // Nothing is announced twice. What has already been told is stamped in // `UserDefaults` by id for the two boards and by threshold for storage, so // crossing 80% announces once rather than on every check until somebody frees // space. // import Foundation import UserNotifications @MainActor final class AdminAlertWatcher: ObservableObject { static let shared = AdminAlertWatcher() private init() {} // MARK: Preferences /// Each alert is its own switch. An operator who wants to know about storage /// does not necessarily want to be told about every feature request, and a /// single "notifications" toggle would make them take both or neither. static let storageKey = "FTStoreAdmin.alerts.storage" static let feedbackKey = "FTStoreAdmin.alerts.feedback" static let requestsKey = "FTStoreAdmin.alerts.requests" /// A device got in, or one got in from a country no device had come from /// before. Only meaningful while private mode is on, and the server says so /// rather than this app guessing from the numbers. static let accessKey = "FTStoreAdmin.alerts.access" /// The fraction of the configured cap that counts as "getting full". /// /// 80% rather than the cap itself because the cap is where downloads stop: /// being told then is being told too late. The server already logs the /// crossing of the cap, so this is the warning that comes first. static let storageThreshold = 0.8 /// A moment rather than an id, and therefore a new key. Reading the old one /// back as a `Double` would give a small number that every timestamp is /// greater than, so the first run after this shipped would have announced a /// device that had been signed in for months. A fresh key records instead. private static let _seenAccessKey = "FTStoreAdmin.alerts.seenAccessAt" /// Which countries have already been seen holding a pass, as a stored list. /// A set rather than a high-water mark, because a country is not a number /// and "somewhere new" is a membership test. private static let _seenCountriesKey = "FTStoreAdmin.alerts.seenCountries" private static let _seenFeedbackKey = "FTStoreAdmin.alerts.seenFeedbackId" private static let _seenRequestKey = "FTStoreAdmin.alerts.seenRequestId" private static let _warnedStorageKey = "FTStoreAdmin.alerts.warnedStorage" nonisolated static func isOn(_ key: String) -> Bool { // Off until asked for. An FTStore Admin build that started notifying on its own would // be one that asked for permission on its own. UserDefaults.standard.bool(forKey: key) } nonisolated var anyEnabled: Bool { Self.isOn(Self.storageKey) || Self.isOn(Self.feedbackKey) || Self.isOn(Self.requestsKey) || Self.isOn(Self.accessKey) } /// Ask iOS, once the operator has turned something on rather than at launch. static func requestAuthorization() { UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } // MARK: Checking /// Fetch the numbers and announce whatever is new. /// /// Returns whether anything was posted, which is what the background task /// reports back to iOS: a refresh that never finds anything is one the /// system should schedule less often. @discardableResult func check(session: AdminSession = .shared) async -> Bool { guard anyEnabled else { return false } // `hasStoredSession` and not `isSignedIn`: the biometric gate is a lock on // the screen, not on the credential, and an FTStore Admin build that re-locked itself // on the way to the background is exactly the one that should still be // able to tell somebody the disk is filling up. guard session.hasStoredSession else { return false } let alerts: AdminAlerts do { alerts = try await session.get("/api/dashboard/alerts", as: AdminAlerts.self) } catch { // Silent on purpose. A failed background poll is not something to // wake somebody up about, and FTStore Admin shows the real error the // next time a screen loads. return false } guard let data = alerts.data else { return false } var posted = false posted = _checkStorage(data.storage) || posted posted = _checkFeedback(data.feedback) || posted posted = _checkRequests(data.tweakRequests) || posted if let access = data.access { posted = _checkAccess(access) || posted } return posted } // MARK: The three private func _checkStorage(_ storage: AdminAlerts.Storage) -> Bool { guard Self.isOn(Self.storageKey), storage.hasLimit, let ratio = storage.ratio else { return false } let defaults = UserDefaults.standard let warned = defaults.bool(forKey: Self._warnedStorageKey) // Below the line again: re-arm, so the next crossing is announced. This // is what stops "80% reached" repeating every check for a week. if ratio < Self.storageThreshold { if warned { defaults.set(false, forKey: Self._warnedStorageKey) } return false } if warned { return false } defaults.set(true, forKey: Self._warnedStorageKey) _post( id: "storage", title: storage.overLimit ? .localized("Storage is full") : .localized("Storage is filling up"), body: String.localized( "%@ of %@ used (%lld%%).", AdminFormat.bytes(storage.usedBytes), AdminFormat.bytes(storage.limitBytes), Int((ratio * 100).rounded()) ) ) return true } private func _checkFeedback(_ feedback: AdminAlerts.Feedback) -> Bool { guard Self.isOn(Self.feedbackKey), let newest = feedback.newestId else { return false } let defaults = UserDefaults.standard let seen = defaults.integer(forKey: Self._seenFeedbackKey) defaults.set(newest, forKey: Self._seenFeedbackKey) // A first run records where things stand and says nothing. Announcing // the newest of whatever already existed would be a notification about // something the operator has probably already read. guard seen > 0, newest > seen else { return false } _post( id: "feedback", title: newest - seen == 1 ? .localized("New feedback") : String.localized("%lld new reports", newest - seen), body: feedback.newestTitle ?? .localized("Open the board to read it.") ) return true } private func _checkRequests(_ requests: AdminAlerts.TweakRequests) -> Bool { guard Self.isOn(Self.requestsKey), let newest = requests.newestId else { return false } let defaults = UserDefaults.standard let seen = defaults.integer(forKey: Self._seenRequestKey) defaults.set(newest, forKey: Self._seenRequestKey) guard seen > 0, newest > seen else { return false } _post( id: "requests", title: newest - seen == 1 ? .localized("New tweak request") : String.localized("%lld new tweak requests", newest - seen), body: [requests.newestName, requests.newestSource] .compactMap { $0 } .joined(separator: " · ") ) return true } /// A device got in, or one got in from somewhere new. /// /// Two announcements from one payload rather than two switches, because they /// are the same question asked at two scales: somebody let themselves in, /// and somebody let themselves in from a place nobody has before. An /// operator who wants one wants the other. /// /// Silent when private mode is off. Passes only exist while it is on, so a /// server that has never used it has nothing to say, and one that has just /// been opened up should not start announcing its own history. private func _checkAccess(_ access: AdminAlerts.Access) -> Bool { guard Self.isOn(Self.accessKey), access.isPrivate else { return false } let defaults = UserDefaults.standard var posted = false // A country nobody had come in from before. Checked first, because it is // the more alarming of the two and the new device is usually the same // event seen from closer up. let known = Set(defaults.stringArray(forKey: Self._seenCountriesKey) ?? []) let current = Set(access.countries) defaults.set(Array(current).sorted(), forKey: Self._seenCountriesKey) // A first run records where things stand and says nothing, the same rule // the two boards follow: announcing what already existed is a // notification about something the operator set up themselves. if !known.isEmpty { let arrived = current.subtracting(known).sorted() if !arrived.isEmpty { _post( id: "access-country", title: .localized("Access from a new country"), body: arrived .map { "\(AdminCountry.flag($0)) \(AdminCountry.name($0))" } .joined(separator: ", ") ) posted = true } } // The two boards above compare ids, which only ever go up. A session has // no id — its key is the digest of a token — so this compares the moment // of the newest sign-in instead. Same question, same rule about the first // run, and it cannot say *how many* are new, which is why the title does // not try to. guard let newest = access.newestAt else { return posted } let seen = defaults.double(forKey: Self._seenAccessKey) defaults.set(newest.timeIntervalSince1970, forKey: Self._seenAccessKey) guard seen > 0, newest.timeIntervalSince1970 > seen else { return posted } _post( id: "access-device", title: .localized("A new device signed in"), body: [ access.newestLabel, access.newestDevice, access.newestCountry.map { AdminCountry.name($0) }, String.localized("%lld in total", access.active), ] .compactMap { $0 } .joined(separator: " · ") ) return true } /// One notification per kind, replaced rather than stacked. /// /// A fixed identifier per kind is deliberate: three unread "new feedback" /// banners say nothing the newest one does not, and a lock screen with /// twenty of them is one the operator stops reading. private func _post(id: String, title: String, body: String) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default UNUserNotificationCenter.current().add( UNNotificationRequest( identifier: "FTStoreAdmin.alert.\(id)", content: content, trigger: nil ) ) } }