diff --git a/PennMobile.xcodeproj/project.pbxproj b/PennMobile.xcodeproj/project.pbxproj index 3a98e6d18..961550a86 100644 --- a/PennMobile.xcodeproj/project.pbxproj +++ b/PennMobile.xcodeproj/project.pbxproj @@ -376,6 +376,7 @@ 895A1AB82CB98F5000E161AE /* ScrollableGraphView in Frameworks */ = {isa = PBXBuildFile; productRef = 895A1AB72CB98F5000E161AE /* ScrollableGraphView */; }; 895A1ABB2CB98F7100E161AE /* SCLAlertView in Frameworks */ = {isa = PBXBuildFile; productRef = 895A1ABA2CB98F7100E161AE /* SCLAlertView */; }; 898DB4912B2E7AA20027CC8F /* PennForms in Frameworks */ = {isa = PBXBuildFile; productRef = 898DB4902B2E7AA20027CC8F /* PennForms */; }; + 89DF63072CEB9BBB00C4A015 /* NotificationDeviceTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DF63062CEB9BB400C4A015 /* NotificationDeviceTokenManager.swift */; }; 89EA262E290F9411008F26CF /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 89EA262D290F9411008F26CF /* Intents.intentdefinition */; }; 89EA262F290F958B008F26CF /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 89EA262D290F9411008F26CF /* Intents.intentdefinition */; }; F213CCE223C3EE3E000AD90F /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F213CCE123C3EE3E000AD90F /* SwiftSoup */; }; @@ -807,6 +808,7 @@ 8932693328FC75A5003D4BF9 /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 8932693428FC75A5003D4BF9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 8932693628FC75A5003D4BF9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 89DF63062CEB9BB400C4A015 /* NotificationDeviceTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDeviceTokenManager.swift; sourceTree = ""; }; 89EA262D290F9411008F26CF /* Intents.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = Intents.intentdefinition; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1750,6 +1752,7 @@ 42632C4C2CB9C77B0028CC31 /* NotificationRequestable.swift */, 42632C4D2CB9C77B0028CC31 /* NotificationsTableViewCell.swift */, 42632C4E2CB9C77B0028CC31 /* NotificationsTableViewController.swift */, + 89DF63062CEB9BB400C4A015 /* NotificationDeviceTokenManager.swift */, ); path = Notifications; sourceTree = ""; @@ -2454,6 +2457,7 @@ 42632D072CB9C77B0028CC31 /* GSRTabController.swift in Sources */, 42632D082CB9C77B0028CC31 /* GSRAPIResponse.swift in Sources */, 42632D092CB9C77B0028CC31 /* GSRBooking.swift in Sources */, + 89DF63072CEB9BBB00C4A015 /* NotificationDeviceTokenManager.swift in Sources */, 42632D0A2CB9C77B0028CC31 /* GSRDateHandler.swift in Sources */, 42632D0B2CB9C77B0028CC31 /* GSRGroup.swift in Sources */, 42632D0C2CB9C77B0028CC31 /* GSRGroupUser.swift in Sources */, @@ -2850,7 +2854,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = navigation; - CF_BUNDLE_SHORT_VERSION_STRING = 8.0.6; + CF_BUNDLE_SHORT_VERSION_STRING = 8.0.7; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PennMobile/PennMobile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; @@ -2891,7 +2895,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = navigation; - CF_BUNDLE_SHORT_VERSION_STRING = 8.0.6; + CF_BUNDLE_SHORT_VERSION_STRING = 8.0.7; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PennMobile/PennMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; @@ -3023,7 +3027,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = navigation; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CF_BUNDLE_SHORT_VERSION_STRING = 8.0.6; + CF_BUNDLE_SHORT_VERSION_STRING = 8.0.7; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; @@ -3066,7 +3070,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = navigation; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CF_BUNDLE_SHORT_VERSION_STRING = 8.0.6; + CF_BUNDLE_SHORT_VERSION_STRING = 8.0.7; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; diff --git a/PennMobile.xcodeproj/xcshareddata/xcschemes/PennMobile.xcscheme b/PennMobile.xcodeproj/xcshareddata/xcschemes/PennMobile.xcscheme index bfcb2553e..952d78b9c 100755 --- a/PennMobile.xcodeproj/xcshareddata/xcschemes/PennMobile.xcscheme +++ b/PennMobile.xcodeproj/xcshareddata/xcschemes/PennMobile.xcscheme @@ -107,13 +107,6 @@ isEnabled = "YES"> - - - - ) -> Void) { - OAuth2NetworkManager.instance.getAccessToken { (token) in - guard let token = token else { - completion(.failure(.authenticationError)) - return - } - let url = URL(string: "https://pennmobile.org/api/user/notifications/tokens/")! - var params: [String: Any] = [ - "dev": false - ] - - #if DEBUG - params["dev"] = true - #endif - - let request = URLRequest(url: url, accessToken: token) - let task = URLSession.shared.dataTask(with: request) { data, _, _ in - guard let data = data else { - completion(.failure(.serverError)) - return - } - - let decoder = JSONDecoder() - if let response = try? - decoder.decode([GetNotificationID].self, from: data) { - completion(.success(response)) - } else { - completion(.failure(.parsingError)) - } - } - task.resume() - } - } - - // Updates device token. - func savePushNotificationDeviceToken(deviceToken: String, notifId: Int, _ completion: (() -> Void)? = nil) { - Task { - defer { completion?() } - - struct DeviceTokenRequestBody: Encodable { - var kind: String - var token: String - var dev: Bool - } - - guard let url = URL(string: "https://pennmobile.org/api/user/notifications/tokens/\(notifId)/") else { - return - } - - var body = DeviceTokenRequestBody(kind: "IOS", token: deviceToken, dev: false) - -#if DEBUG - body.dev = true -#endif - - guard let token = try? await OAuth2NetworkManager.instance.getAccessToken() else { - return - } - - var request = URLRequest(url: url, accessToken: token) - request.httpMethod = "PUT" - - // If serializing a simple JSON object fails something is really wrong - request.httpBody = try! JSONEncoder().encode(body) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - _ = try? await URLSession.shared.data(for: request) - } - } - - func clearPushNotificationDeviceToken(_ completion: (() -> Void)? = nil) { - let url = "\(baseUrl)/notifications/register" - makePostRequestWithAccessToken(url: url, params: [:]) { (_, _, _) in - completion?() - } - } -} - // MARK: - Anonymized Token Registration extension UserDBManager { /// Updates the anonymization keys in case either of them changed. The only key that may change is the pennkey-password. diff --git a/PennMobile/Login/AuthManager.swift b/PennMobile/Login/AuthManager.swift index b3d8d40ad..93e39b028 100644 --- a/PennMobile/Login/AuthManager.swift +++ b/PennMobile/Login/AuthManager.swift @@ -92,3 +92,18 @@ class AuthManager: ObservableObject { AuthManager.clearAccountData() } } + +extension AuthState: CustomDebugStringConvertible { + var debugDescription: String { + switch self { + case .notDetermined: + "Not determined" + case .loggedOut: + "Logged out" + case .guest: + "Guest" + case .loggedIn(let account): + "Logged in as \(account.username)" + } + } +} diff --git a/PennMobile/Notifications/NotificationDeviceTokenManager.swift b/PennMobile/Notifications/NotificationDeviceTokenManager.swift new file mode 100644 index 000000000..274fddc08 --- /dev/null +++ b/PennMobile/Notifications/NotificationDeviceTokenManager.swift @@ -0,0 +1,190 @@ +// +// NotificationDeviceTokenManager.swift +// PennMobile +// +// Created by Anthony Li on 11/18/24. +// Copyright © 2024 PennLabs. All rights reserved. +// + +import PennMobileShared +import OSLog + +@globalActor actor NotificationDeviceTokenManager { + private static let logger = Logger(category: "NotificationDeviceTokenManager") + + enum TokenAction { + case sendToken + case deleteToken + } + + static let shared = NotificationDeviceTokenManager() + + private var cachedToken: (data: Data, fromMemory: Bool, actionTaken: TokenAction?)? + private var pendingAction: TokenAction? + + private var currentTask: Task? + + private let storageName = "notificationDeviceToken" + + private init() { + if let token = try? Storage.retrieveThrowing(storageName, from: .caches, as: Data.self) { + cachedToken = (data: token, fromMemory: false, actionTaken: nil) + Self.logger.info("Notification token restored from cache (\(token.count) bytes)") + } + } + + func tokenReceived(_ token: Data) { + Self.logger.info("Notification token received from app delegate (\(token.count) bytes)") + + let oldToken = cachedToken + cachedToken = (data: token, fromMemory: true, actionTaken: pendingAction) + + guard let pendingAction else { + return + } + + if let oldToken, oldToken.actionTaken == pendingAction, oldToken.data == token { + return + } + + currentTask?.cancel() + switch pendingAction { + case .deleteToken: + currentTask = Task { + try? await delete(token: token) + } + case .sendToken: + currentTask = Task { + try? await send(token: token) + } + } + + try? Storage.storeThrowing(token, to: .caches, as: storageName) + } + + func authStateDetermined(_ state: AuthState) { + Self.logger.debug("Notification token manager got auth state: \(state.debugDescription)") + + switch state { + case .loggedIn: + if pendingAction != .sendToken { + currentTask?.cancel() + pendingAction = .sendToken + + if let cachedToken, cachedToken.fromMemory { + self.cachedToken!.actionTaken = .sendToken + + currentTask = Task { + try? await send(token: cachedToken.data) + } + } + } + case .loggedOut, .guest: + if pendingAction != .deleteToken { + currentTask?.cancel() + pendingAction = .deleteToken + + if let cachedToken { + self.cachedToken!.actionTaken = .deleteToken + + currentTask = Task { + try? await delete(token: cachedToken.data) + } + } + } + case .notDetermined: + break + } + } + + private func url(for token: Data) -> URL { + let hex = token.map { byte -> String in + return String(format: "%02.2hhx", byte) + }.joined() + + return URL(string: "https://pennmobile.org/api/user/notifications/tokens/ios/\(hex)/")! + } + + private func send(token: Data) async throws { + do { + Self.logger.info("Uploading notification token") + + let url = url(for: token) + var request = try await URLRequest(authenticatedUrl: url) + request.httpMethod = "POST" + + struct TokenUploadRequest: Encodable { + #if DEBUG + var is_dev = true + #else + var is_dev = false + #endif + } + + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(TokenUploadRequest()) + + let data: Data + let response: URLResponse + + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + Self.logger.error("Couldn't upload notification token: \(error)") + throw error + } + + guard let response = response as? HTTPURLResponse else { + Self.logger.error("Couldn't upload notification token: got unexpected response type") + throw NetworkingError.serverError + } + + guard (200..<300).contains(response.statusCode) else { + Self.logger.error("Couldn't upload notification token: got unexpected status code \(response.statusCode)") + throw NetworkingError.serverError + } + + if let str = String(data: data, encoding: .utf8) { + Self.logger.info("Notification token uploaded, response: \(str)") + } else { + Self.logger.info("Notification token uploaded, response not decodable") + } + } catch { + Self.logger.error("Couldn't upload notification token: \(error)") + } + } + + private func delete(token: Data) async throws { + Self.logger.info("Deleting notification token") + + let url = url(for: token) + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + + let data: Data + let response: URLResponse + + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + Self.logger.error("Couldn't delete notification token: \(error)") + throw error + } + + guard let response = response as? HTTPURLResponse else { + Self.logger.error("Couldn't delete notification token: got unexpected response type") + throw NetworkingError.serverError + } + + guard (200..<300).contains(response.statusCode) else { + Self.logger.error("Couldn't delete notification token: got unexpected status code \(response.statusCode)") + throw NetworkingError.serverError + } + + if let str = String(data: data, encoding: .utf8) { + Self.logger.info("Notification token deleted, response: \(str)") + } else { + Self.logger.info("Notification token deleted, response not decodable") + } + } +} diff --git a/PennMobile/Notifications/SwiftUI/NotificationsView.swift b/PennMobile/Notifications/SwiftUI/NotificationsView.swift index b18269d87..a18f481a7 100644 --- a/PennMobile/Notifications/SwiftUI/NotificationsView.swift +++ b/PennMobile/Notifications/SwiftUI/NotificationsView.swift @@ -73,7 +73,7 @@ extension NotificationsView { } func showNotificationsUndeterminedError() -> Alert { - return Alert(title: Text("Enable Notifications"), message: Text("Receive monthly dining plan progress updates, laundry alerts, and information about new features."), primaryButton: .default(Text("Don't Allow"), action: { dismiss() }), secondaryButton: .default(Text("OK"), action: { + return Alert(title: Text("Enable Notifications"), message: Text("Receive Penn Course Alert notifications (when available), laundry alerts, and information about new features."), primaryButton: .default(Text("Don't Allow"), action: { dismiss() }), secondaryButton: .default(Text("OK"), action: { registerPushNotification { (granted) in DispatchQueue.main.async { if granted { diff --git a/PennMobile/Setup + Navigation/AppDelegate+NotificationExtension.swift b/PennMobile/Setup + Navigation/AppDelegate+NotificationExtension.swift index f5fd63e3e..16cd40ad6 100755 --- a/PennMobile/Setup + Navigation/AppDelegate+NotificationExtension.swift +++ b/PennMobile/Setup + Navigation/AppDelegate+NotificationExtension.swift @@ -24,19 +24,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - let tokenParts = deviceToken.map { data -> String in - return String(format: "%02.2hhx", data) + Task { + await NotificationDeviceTokenManager.shared.tokenReceived(deviceToken) } - let token = tokenParts.joined() - - UserDBManager.shared.getNotificationId { result in - if let getIdResp = try? result.get()[0] { - let notificationId: Int = getIdResp.id - UserDBManager.shared.savePushNotificationDeviceToken(deviceToken: token, notifId: notificationId) - } - } - // Setup rich notification categories let cancelGSRBookingAction = UNNotificationAction(identifier: NotificationIdentifiers.cancelGSRAction, title: "Cancel Booking", options: [.foreground]) let shareGSRBookingAction = UNNotificationAction(identifier: NotificationIdentifiers.shareGSRAction, title: "Share", options: [.foreground]) diff --git a/PennMobile/Setup + Navigation/PennMobile.swift b/PennMobile/Setup + Navigation/PennMobile.swift index f021dc994..eac147b67 100644 --- a/PennMobile/Setup + Navigation/PennMobile.swift +++ b/PennMobile/Setup + Navigation/PennMobile.swift @@ -41,6 +41,11 @@ struct PennMobile: App { UserDBManager.shared.loginToBackend() migrateDataToGroupContainer() + + let state = authManager.state + Task { + await NotificationDeviceTokenManager.shared.authStateDetermined(state) + } } var body: some Scene { @@ -57,5 +62,10 @@ struct PennMobile: App { .onChange(of: authManager.state.isLoggedIn) { _ in homeViewModel.clearData() } + .onChange(of: authManager.state) { state in + Task { + await NotificationDeviceTokenManager.shared.authStateDetermined(state) + } + } } }