Tusker/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift

347 lines
13 KiB
Swift

//
// PushManagerImpl.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import UIKit
import UserAccounts
import CryptoKit
class PushManagerImpl: _PushManager {
private let endpoint: URL
var enabled: Bool {
true
}
private var apnsEnvironment: String {
#if DEBUG
"development"
#else
"release"
#endif
}
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
private(set) var proxyRegistration: PushProxyRegistration? {
get {
if let dict = defaults.dictionary(forKey: "PushProxyRegistration") as? [String: String],
let registration = PushProxyRegistration(defaultsDict: dict) {
return registration
} else {
return nil
}
}
set {
defaults.setValue(newValue?.defaultsDict, forKey: "PushProxyRegistration")
}
}
private(set) var subscriptions: [PushSubscription] {
get {
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
return array.compactMap(PushSubscription.init(defaultsDict:))
} else {
return []
}
}
set {
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
}
}
init(endpoint: URL) {
self.endpoint = endpoint
}
func createSubscription(account: UserAccountInfo) throws -> PushSubscription {
guard let proxyRegistration else {
throw CreateSubscriptionError.notRegisteredWithProxy
}
if let existing = pushSubscription(account: account) {
return existing
}
let key = P256.KeyAgreement.PrivateKey()
var authSecret = Data(count: 16)
let res = authSecret.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
}
guard res == errSecSuccess else {
throw CreateSubscriptionError.generatingAuthSecret(res)
}
let subscription = PushSubscription(
accountID: account.id,
endpoint: endpointURL(registration: proxyRegistration, accountID: account.id),
secretKey: key,
authSecret: authSecret,
alerts: [],
policy: .all
)
subscriptions.append(subscription)
return subscription
}
private func endpointURL(registration: PushProxyRegistration, accountID: String) -> URL {
var endpoint = URLComponents(url: registration.endpoint, resolvingAgainstBaseURL: false)!
endpoint.queryItems = endpoint.queryItems ?? []
endpoint.queryItems!.append(URLQueryItem(name: "ctx", value: accountID))
return endpoint.url!
}
func removeSubscription(account: UserAccountInfo) {
subscriptions.removeAll { $0.accountID == account.id }
}
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
guard let index = subscriptions.firstIndex(where: { $0.accountID == account.id }) else {
return
}
var copy = subscriptions[index]
copy.alerts = alerts
copy.policy = policy
subscriptions[index] = copy
}
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
subscriptions.first { $0.accountID == account.id }
}
func register() async throws -> PushProxyRegistration {
guard remoteNotificationsRegistrationContinuation == nil else {
throw PushRegistrationError.alreadyRegistering
}
let deviceToken = try await getDeviceToken().hexEncodedString()
PushManager.logger.debug("Got device token: \(deviceToken)")
let registration: PushProxyRegistration
do {
registration = try await register(deviceToken: deviceToken)
PushManager.logger.debug("Got endpoint: \(registration.endpoint)")
} catch {
PushManager.logger.error("Proxy registration failed: \(String(describing: error))")
throw PushRegistrationError.registeringWithProxy(error)
}
proxyRegistration = registration
return registration
}
func unregister() async throws {
guard let proxyRegistration else {
return
}
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations/\(proxyRegistration.id)"
var request = URLRequest(url: url.url!)
request.httpMethod = "DELETE"
let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode
if (200...299).contains(status) {
self.proxyRegistration = nil
PushManager.logger.debug("Unregistered from proxy")
} else {
PushManager.logger.error("Unregistering: unexpected status \(status)")
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: nil)
throw PushRegistrationError.unregistering(error)
}
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
guard let proxyRegistration else {
return
}
PushManager.logger.debug("Push proxy registration: \(proxyRegistration.id, privacy: .public)")
do {
let token = try await getDeviceToken().hexEncodedString()
guard token != proxyRegistration.deviceToken else {
// already up-to-date, nothing to do
return
}
let newRegistration = try await update(registration: proxyRegistration, deviceToken: token)
self.proxyRegistration = newRegistration
if proxyRegistration.endpoint != newRegistration.endpoint {
self.subscriptions = await AsyncSequenceAdaptor(wrapping: self.subscriptions).map {
var copy = $0
copy.endpoint = await self.endpointURL(registration: newRegistration, accountID: $0.accountID)
if await updateSubscription(copy) {
return copy
} else {
return $0
}
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
}
} catch {
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
PushManager.captureError?(error)
}
}
private func getDeviceToken() async throws -> Data {
defer {
remoteNotificationsRegistrationContinuation = nil
}
return try await withCheckedThrowingContinuation { continuation in
remoteNotificationsRegistrationContinuation = continuation
UIApplication.shared.registerForRemoteNotifications()
}
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
}
func didFailToRegisterForRemoteNotifications(error: any Error) {
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
}
private func register(deviceToken: String) async throws -> PushProxyRegistration {
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations"
var request = URLRequest(url: url.url!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.httpBody = try! JSONEncoder().encode(PushRegistrationParams(environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1))
let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode
guard (200...299).contains(status) else {
PushManager.logger.error("Registering: unexpected status \(status)")
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: [])
throw error
}
return try JSONDecoder().decode(PushProxyRegistration.self, from: data)
}
private func update(registration: PushProxyRegistration, deviceToken: String) async throws -> PushProxyRegistration {
var url = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
url.path = "/app/v1/registrations/\(registration.id)"
var request = URLRequest(url: url.url!)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.httpBody = try! JSONEncoder().encode(PushUpdateParams(environment: apnsEnvironment, deviceToken: deviceToken, pushVersion: 1))
let (data, resp) = try await URLSession.shared.data(for: request)
let status = (resp as! HTTPURLResponse).statusCode
guard (200...299).contains(status) else {
PushManager.logger.error("Updating: unexpected status \(status)")
let error = (try? JSONDecoder().decode(ProxyRegistrationError.self, from: data)) ?? ProxyRegistrationError(error: "Unknown error", fields: [])
throw error
}
return try JSONDecoder().decode(PushProxyRegistration.self, from: data)
}
}
enum PushRegistrationError: LocalizedError {
case alreadyRegistering
case registeringForRemoteNotifications(any Error)
case registeringWithProxy(any Error)
case unregistering(any Error)
var errorDescription: String? {
switch self {
case .alreadyRegistering:
"Already registering"
case .registeringForRemoteNotifications(let error):
"Remote notifications: \(error.localizedDescription)"
case .registeringWithProxy(let error):
"Proxy: \(error.localizedDescription)"
case .unregistering(let error):
"Unregistering: \(error.localizedDescription)"
}
}
}
struct ProxyRegistrationError: LocalizedError, Decodable {
let error: String
let fields: [Field]?
var errorDescription: String? {
if let fields,
!fields.isEmpty {
error + ": " + fields.map { "\($0.key): \($0.reason)" }.joined(separator: ", ")
} else {
error
}
}
struct Field: Decodable {
let key: String
let reason: String
}
}
enum CreateSubscriptionError: LocalizedError {
case notRegisteredWithProxy
case generatingAuthSecret(OSStatus)
var errorDescription: String? {
switch self {
case .notRegisteredWithProxy:
"Not registered with proxy"
case .generatingAuthSecret(let code):
"Generating auth secret: \(code)"
}
}
}
private struct PushRegistrationParams: Encodable {
let environment: String
let deviceToken: String
let pushVersion: Int
enum CodingKeys: String, CodingKey {
case environment
case deviceToken = "device_token"
case pushVersion = "push_version"
}
}
private struct PushUpdateParams: Encodable {
let environment: String
let deviceToken: String
let pushVersion: Int
enum CodingKeys: String, CodingKey {
case environment
case deviceToken = "device_token"
case pushVersion = "push_version"
}
}
private extension Data {
func hexEncodedString() -> String {
String(unsafeUninitializedCapacity: count * 2) { buffer in
let chars = Array("0123456789ABCDEF".utf8)
for (i, x) in enumerated() {
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
buffer[i * 2] = chars[Int(upper)]
buffer[i * 2 + 1] = chars[Int(lower)]
}
return count * 2
}
}
}
private struct AsyncSequenceAdaptor<S: Sequence>: AsyncSequence {
typealias Element = S.Element
let base: S
init(wrapping base: S) {
self.base = base
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(base: base.makeIterator())
}
struct AsyncIterator: AsyncIteratorProtocol {
var base: S.Iterator
mutating func next() async -> Element? {
base.next()
}
}
}