From 19ca930ee8caa20cebe177a6de0c3f02394d4122 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 12 Apr 2024 16:15:52 -0400 Subject: [PATCH] Remove the need to register with the push proxy --- .../NotificationService.swift | 2 +- .../DisabledPushManager.swift | 14 +- .../PushNotifications/PushManager.swift | 5 +- .../PushNotifications/PushManagerImpl.swift | 195 +++--------------- .../PushProxyRegistration.swift | 39 ---- .../NotificationsPrefsView.swift | 107 +--------- .../PushInstanceSettingsView.swift | 16 +- 7 files changed, 40 insertions(+), 338 deletions(-) delete mode 100644 Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift index c17edaf5e2..9db74413a7 100644 --- a/NotificationExtension/NotificationService.swift +++ b/NotificationExtension/NotificationService.swift @@ -32,7 +32,7 @@ class NotificationService: UNNotificationServiceExtension { } guard request.content.userInfo["v"] as? Int == 1, - let accountID = request.content.userInfo["ctx"] as? String, + let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding), let account = UserAccountsManager.shared.getAccount(id: accountID), let subscription = getSubscription(account: account), let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }), diff --git a/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift b/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift index 28af0f5f80..2606621e83 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/DisabledPushManager.swift @@ -13,15 +13,11 @@ class DisabledPushManager: _PushManager { false } - var proxyRegistration: PushProxyRegistration? { - nil - } - var subscriptions: [PushSubscription] { [] } - func createSubscription(account: UserAccountInfo) throws -> PushSubscription { + func createSubscription(account: UserAccountInfo) async throws -> PushSubscription { throw Disabled() } @@ -35,14 +31,6 @@ class DisabledPushManager: _PushManager { nil } - func register() async throws -> PushProxyRegistration { - throw Disabled() - } - - func unregister() async throws { - throw Disabled() - } - func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async { } diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift b/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift index 16e5d22304..bb1153b757 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushManager.swift @@ -41,16 +41,13 @@ public struct PushManager { @MainActor public protocol _PushManager { var enabled: Bool { get } - var proxyRegistration: PushProxyRegistration? { get } var subscriptions: [PushSubscription] { get } - func createSubscription(account: UserAccountInfo) throws -> PushSubscription + func createSubscription(account: UserAccountInfo) async throws -> PushSubscription func removeSubscription(account: UserAccountInfo) func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) func pushSubscription(account: UserAccountInfo) -> PushSubscription? - func register() async throws -> PushProxyRegistration - func unregister() async throws func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async func didRegisterForRemoteNotifications(deviceToken: Data) diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift b/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift index 120c01032b..8cdf477f32 100644 --- a/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift +++ b/Packages/PushNotifications/Sources/PushNotifications/PushManagerImpl.swift @@ -20,26 +20,13 @@ class PushManagerImpl: _PushManager { #if DEBUG "development" #else - "release" + "production" #endif } private var remoteNotificationsRegistrationContinuation: CheckedContinuation? 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") - } - } public private(set) var subscriptions: [PushSubscription] { get { if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] { @@ -57,10 +44,7 @@ class PushManagerImpl: _PushManager { self.endpoint = endpoint } - func createSubscription(account: UserAccountInfo) throws -> PushSubscription { - guard let proxyRegistration else { - throw CreateSubscriptionError.notRegisteredWithProxy - } + func createSubscription(account: UserAccountInfo) async throws -> PushSubscription { if let existing = pushSubscription(account: account) { return existing } @@ -72,9 +56,10 @@ class PushManagerImpl: _PushManager { guard res == errSecSuccess else { throw CreateSubscriptionError.generatingAuthSecret(res) } + let token = try await getDeviceToken() let subscription = PushSubscription( accountID: account.id, - endpoint: endpointURL(registration: proxyRegistration, accountID: account.id), + endpoint: endpointURL(deviceToken: token, accountID: account.id), secretKey: key, authSecret: authSecret, alerts: [], @@ -84,10 +69,10 @@ class PushManagerImpl: _PushManager { 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)) + private func endpointURL(deviceToken: Data, accountID: String) -> URL { + var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! + endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)" return endpoint.url! } @@ -109,70 +94,28 @@ class PushManagerImpl: _PushManager { 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 { + let subscriptions = self.subscriptions + guard !subscriptions.isEmpty 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) - }) - } + let token = try await getDeviceToken() + self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map { + let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID) + guard newEndpoint != $0.endpoint else { + return $0 + } + var copy = $0 + copy.endpoint = newEndpoint + 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) @@ -195,47 +138,11 @@ class PushManagerImpl: _PushManager { remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error)) remoteNotificationsRegistrationContinuation = nil } - - 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 { @@ -243,71 +150,21 @@ enum PushRegistrationError: LocalizedError { "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 diff --git a/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift b/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift deleted file mode 100644 index 0e5c46f29c..0000000000 --- a/Packages/PushNotifications/Sources/PushNotifications/PushProxyRegistration.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PushProxyRegistration.swift -// PushNotifications -// -// Created by Shadowfacts on 4/7/24. -// - -import Foundation - -public struct PushProxyRegistration: Decodable { - let id: String - public let endpoint: URL - let deviceToken: String - - var defaultsDict: [String: String] { - [ - "id": id, - "endpoint": endpoint.absoluteString, - "deviceToken": deviceToken, - ] - } - - init?(defaultsDict: [String: String]) { - guard let id = defaultsDict["id"], - let endpoint = defaultsDict["endpoint"].flatMap(URL.init(string:)), - let deviceToken = defaultsDict["deviceToken"] else { - return nil - } - self.id = id - self.endpoint = endpoint - self.deviceToken = deviceToken - } - - private enum CodingKeys: String, CodingKey { - case id - case endpoint - case deviceToken = "device_token" - } -} diff --git a/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift b/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift index 062df05eeb..3a59987340 100644 --- a/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift +++ b/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift @@ -14,127 +14,30 @@ import TuskerComponents struct NotificationsPrefsView: View { @State private var error: NotificationsSetupError? - @State private var isSetup = AsyncToggle.Mode.off - @State private var pushProxyRegistration: PushProxyRegistration? @ObservedObject private var userAccounts = UserAccountsManager.shared var body: some View { List { - enableSection - if isSetup == .on, - let pushProxyRegistration { - accountsSection(pushProxyRegistration: pushProxyRegistration) + Section { + ForEach(userAccounts.accounts) { account in + PushInstanceSettingsView(account: account) + } } + .appGroupedListRowBackground() } .listStyle(.insetGrouped) .appGroupedListBackground(container: PreferencesNavigationController.self) .navigationTitle("Notifications") } - - private var enableSection: some View { - Section { - AsyncToggle("Push Notifications", mode: $isSetup, onChange: isSetupChanged(newValue:)) - } - .appGroupedListRowBackground() - .alertWithData("An Error Occurred", data: $error) { error in - Button("OK") {} - } message: { error in - Text(error.localizedDescription) - } - .task { @MainActor in - pushProxyRegistration = PushManager.shared.proxyRegistration - isSetup = pushProxyRegistration != nil ? .on : .off - if !UIApplication.shared.isRegisteredForRemoteNotifications { - _ = await registerForRemoteNotifications() - } - } - } - - private func accountsSection(pushProxyRegistration: PushProxyRegistration) -> some View { - Section { - ForEach(userAccounts.accounts) { account in - PushInstanceSettingsView(account: account, pushProxyRegistration: pushProxyRegistration) - } - } - .appGroupedListRowBackground() - } - - private func isSetupChanged(newValue: Bool) async -> Bool { - if newValue { - return await startRegistration() - } else { - return await unregister() - } - } - - private func startRegistration() async -> Bool { - let authorized: Bool - do { - authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .providesAppNotificationSettings]) - } catch { - self.error = .requestingAuthorization(error) - return false - } - guard authorized else { - return false - } - return await registerForRemoteNotifications() - } - - private func registerForRemoteNotifications() async -> Bool { - do { - pushProxyRegistration = try await PushManager.shared.register() - return true - } catch { - self.error = .registering(error) - return false - } - } - - private func unregister() async -> Bool { - do { - try await PushManager.shared.unregister() - pushProxyRegistration = nil - for subscription in PushManager.shared.subscriptions { - if let account = UserAccountsManager.shared.getAccount(id: subscription.accountID) { - let mastodonController = MastodonController.getForAccount(account) - do { - try await mastodonController.deletePushSubscription() - PushManager.shared.removeSubscription(account: account) - PushManager.logger.debug("Push subscription removed on \(account.instanceURL)") - // this is a bit of a hack. the PushInstanceSettingsViews need to know to update - // their @State variables after we remove the subscription - NotificationCenter.default.post(name: .pushSubscriptionRemoved, object: account.id) - } catch { - PushManager.logger.error("Erroring removing push subscription: \(String(describing: error))") - } - } - } - return true - } catch { - self.error = .unregistering(error) - return false - } - } } private enum NotificationsSetupError: LocalizedError { case requestingAuthorization(any Error) - case registering(any Error) - case unregistering(any Error) var errorDescription: String? { switch self { case .requestingAuthorization(let error): "Notifications authorization request failed: \(error.localizedDescription)" - case .registering(let error): - "Registration failed: \(error.localizedDescription)" - case .unregistering(let error): - "Deactivation failed: \(error.localizedDescription)" } } } - -extension Notification.Name { - static let pushSubscriptionRemoved = Notification.Name("Tusker.pushSubscriptionRemoved") -} diff --git a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift index e43acf5803..bf37109540 100644 --- a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift +++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift @@ -14,16 +14,14 @@ import TuskerComponents struct PushInstanceSettingsView: View { let account: UserAccountInfo - let pushProxyRegistration: PushProxyRegistration @State private var mode: AsyncToggle.Mode @State private var error: Error? @State private var subscription: PushNotifications.PushSubscription? @State private var showReLoginRequiredAlert = false @MainActor - init(account: UserAccountInfo, pushProxyRegistration: PushProxyRegistration) { + init(account: UserAccountInfo) { self.account = account - self.pushProxyRegistration = pushProxyRegistration let subscription = PushManager.shared.pushSubscription(account: account) self.subscription = subscription self.mode = subscription == nil ? .off : .on @@ -52,13 +50,6 @@ struct PushInstanceSettingsView: View { } message: { Text("You must grant permission on \(account.instanceURL.host!) to turn on push notifications.") } - .onReceive(NotificationCenter.default - .publisher(for: .pushSubscriptionRemoved) - .filter { ($0.object as? String) == account.id } - ) { _ in - mode = .off - subscription = nil - } } private func updateNotificationsEnabled(enabled: Bool) async -> Bool { @@ -88,6 +79,11 @@ struct PushInstanceSettingsView: View { return false } + let authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .providesAppNotificationSettings]) + guard authorized else { + return false + } + let subscription = try await PushManager.shared.createSubscription(account: account) let mastodonController = await MastodonController.getForAccount(account) do {