gitbay/Push/PushRegistrar.swift

v1.6.2
gitbay-ios/gitbay/Push/PushRegistrar.swift history · blame · raw

128 lines · 5343 bytes

  1import Foundation
  2import Observation
  3import UIKit
  4import UserNotifications
  5
  6/// Registers this install's APNs token against every signed-in account,
  7/// and deregisters one when its account goes away.
  8///
  9/// A device row is keyed on (user, token) server-side, so one install
 10/// holds a row per account. Registering only the active account would
 11/// silently stop notifications from the others the moment you switched.
 12@Observable
 13@MainActor
 14final class PushRegistrar {
 15
 16    private let session: SessionStore
 17    private let defaults: UserDefaults
 18
 19    /// The APNs token as lowercase hex, once iOS has handed it over.
 20    /// Private rather than `private(set)`: this object is in the
 21    /// environment, so a readable token would be one any view could
 22    /// put on a screen.
 23    private var deviceToken: String?
 24
 25    init(session: SessionStore, defaults: UserDefaults = .standard) {
 26        self.session = session
 27        self.defaults = defaults
 28    }
 29
 30    func deviceTokenArrived(_ data: Data) async {
 31        deviceToken = data.map { String(format: "%02x", $0) }.joined()
 32        await registerAll()
 33    }
 34
 35    /// Registers every signed-in account. Apple rotates device tokens,
 36    /// so this runs on launch too; `device add` upserts on the token, so
 37    /// a repeat costs one call and changes nothing.
 38    func registerAll() async {
 39        guard let token = deviceToken else { return }
 40        for account in session.accounts {
 41            await register(account, token: token)
 42        }
 43    }
 44
 45    /// Registers one account, for one that signs in while the app is
 46    /// running. Does nothing until APNs has handed over a token; the
 47    /// account is picked up by `registerAll` when it does.
 48    func register(_ account: Account) async {
 49        guard let token = deviceToken else { return }
 50        await register(account, token: token)
 51    }
 52
 53    private func register(_ account: Account, token: String) async {
 54        guard let client = session.client(for: account) else { return }
 55        nonisolated struct Registered: Decodable, Sendable { let id: Int64 }
 56        do {
 57            let out = try await client.run(
 58                ["notifications", "device", "add", "--label", Self.deviceLabel],
 59                stdin: token, as: Registered.self)
 60            if let id = out?.id {
 61                // Stored as Int, which UserDefaults handles natively —
 62                // it has no Int64 overload, so an Int64 goes in as a
 63                // boxed NSNumber and comes back out through a
 64                // conditional bridge. Int is 64-bit on every device
 65                // this ships to.
 66                defaults.set(Int(id), forKey: Self.idKey(account))
 67            }
 68        } catch {
 69            // A side channel. The account still works and the next
 70            // launch registers again.
 71        }
 72    }
 73
 74    /// Removes this device from one account, using the id that account
 75    /// returned at registration. Must run while the account's token
 76    /// still exists — SessionStore.willRemoveAccount is where it is
 77    /// wired, and that hook is awaited before the token is discarded.
 78    func deregister(_ account: Account) async {
 79        let key = Self.idKey(account)
 80        // object(forKey:) rather than integer(forKey:), which cannot
 81        // tell a stored 0 from an absent key.
 82        guard let id = defaults.object(forKey: key) as? Int,
 83              let client = session.client(for: account) else { return }
 84        try? await client.run(["notifications", "device", "remove", String(id)])
 85        defaults.removeObject(forKey: key)
 86    }
 87
 88    /// Asks iOS for permission and, on approval, registers for remote
 89    /// notifications. Called from the notifications screen's toggle, so
 90    /// the system prompt only appears because the user asked for it.
 91    func requestPushPermission() async -> Bool {
 92        let center = UNUserNotificationCenter.current()
 93        let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
 94        if granted {
 95            UIApplication.shared.registerForRemoteNotifications()
 96        }
 97        return granted
 98    }
 99
100    /// Whether iOS has been asked, and what it said.
101    func authorizationStatus() async -> UNAuthorizationStatus {
102        await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
103    }
104
105    /// Asks iOS for a token when permission is already granted: on
106    /// launch, and when the user grants it in Settings rather than
107    /// through the toggle. Idempotent — `device add` upserts.
108    func registerIfAlreadyAuthorized() async {
109        if await authorizationStatus() == .authorized {
110            UIApplication.shared.registerForRemoteNotifications()
111        }
112    }
113
114    private static func idKey(_ account: Account) -> String {
115        "pushDeviceID#\(account.id)"
116    }
117
118    /// Names the row in `notifications device list` on every surface.
119    ///
120    /// Without the user-assigned-device-name entitlement, iOS already
121    /// answers with a generic, non-empty model name ("iPhone") rather
122    /// than the user's custom name — the `isEmpty` guard is defense in
123    /// depth against a future OS returning nothing at all.
124    private static var deviceLabel: String {
125        let name = UIDevice.current.name
126        return name.isEmpty ? "iPhone" : name
127    }
128}