diff --git a/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift b/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift index d6468dc1c4..58aa1a2ed9 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift +++ b/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift @@ -168,8 +168,14 @@ struct LoginRequestNotification: Codable, Equatable { // MARK: - LoginRequestPushNotification +// TODO: PM-33817 - Remove `LoginRequestPushNotification` once the server fully switches to alert-style +// push notifications and local notification banners are no longer created for auth requests. + /// The data structure of the information attached to the in-app foreground notification. struct LoginRequestPushNotification: Codable, Equatable { + /// The id of the login request. + let id: String? + /// How long until the request times out. let timeoutInMinutes: Int diff --git a/BitwardenShared/Core/Platform/Services/NotificationService.swift b/BitwardenShared/Core/Platform/Services/NotificationService.swift index 541cb0e320..ce52b716c1 100644 --- a/BitwardenShared/Core/Platform/Services/NotificationService.swift +++ b/BitwardenShared/Core/Platform/Services/NotificationService.swift @@ -261,7 +261,10 @@ class DefaultNotificationService: NotificationService { try await syncService.deleteSend(data: data) } case .authRequest: - try await handleLoginRequest(notificationData, userId: userId) + // TODO: PM-33817 Remove isAlertNotification once all auth request pushes are + // alert-style and the silent push path is no longer needed. + let isAlertNotification = (message["aps"] as? [AnyHashable: Any])?["alert"] != nil + try await handleLoginRequest(notificationData, isAlertNotification: isAlertNotification, userId: userId) case .authRequestResponse: // No action necessary, since the LoginWithDeviceProcessor already checks for updates // every few seconds. @@ -330,9 +333,17 @@ class DefaultNotificationService: NotificationService { /// /// - Parameters: /// - notificationData: The decoded payload from the push notification. + /// - isAlertNotification: Whether the push notification is an alert (non-silent) notification. + /// When `true`, the OS has already displayed the notification banner (enriched by the + /// notification service extension), so creating a local notification is skipped to avoid + /// showing the banner twice. /// - userId: The user's id. /// - private func handleLoginRequest(_ notificationData: PushNotificationData, userId: String) async throws { + private func handleLoginRequest( + _ notificationData: PushNotificationData, + isAlertNotification: Bool, + userId: String, + ) async throws { let data: LoginRequestNotification = try notificationData.data() // Get the email of the account that the login request is coming from. @@ -350,30 +361,37 @@ class DefaultNotificationService: NotificationService { // Save the notification data. await stateService.setLoginRequest(data) - // Assemble the data to add to the in-app banner notification. - let loginRequestData = try? JSONEncoder().encode(LoginRequestPushNotification( - timeoutInMinutes: Constants.loginRequestTimeoutMinutes, - userId: loginSourceAccount.profile.userId, - )) - - // Create an in-app banner notification to tell the user about the login request. - let content = UNMutableNotificationContent() - content.title = Localizations.logInRequested - content.body = Localizations.confirmLogInAttemptForX(loginSourceEmail) - content.categoryIdentifier = "dismissableCategory" - if let loginRequestData, - let loginRequestEncoded = String(data: loginRequestData, encoding: .utf8) { - content.userInfo = ["notificationData": loginRequestEncoded] + // For silent (background) pushes, create a local notification banner since the OS won't + // display one. For alert pushes, the OS already displayed the banner via the notification + // service extension, so skip creating a duplicate. + // TODO: PM-33817 Remove this block once all auth request pushes are alert-style. + if !isAlertNotification { + // Assemble the data to add to the in-app banner notification. + let loginRequestData = try? JSONEncoder().encode(LoginRequestPushNotification( + id: data.id, + timeoutInMinutes: Constants.loginRequestTimeoutMinutes, + userId: loginSourceAccount.profile.userId, + )) + + // Create an in-app banner notification to tell the user about the login request. + let content = UNMutableNotificationContent() + content.title = Localizations.logInRequested + content.body = Localizations.confimLogInAttempForX(loginSourceEmail) + content.categoryIdentifier = "dismissableCategory" + if let loginRequestData, + let loginRequestEncoded = String(data: loginRequestData, encoding: .utf8) { + content.userInfo = ["notificationData": loginRequestEncoded] + } + let category = UNNotificationCategory( + identifier: "dismissableCategory", + actions: [.init(identifier: "Clear", title: Localizations.clear, options: [.foreground])], + intentIdentifiers: [], + options: [.customDismissAction], + ) + UNUserNotificationCenter.current().setNotificationCategories([category]) + let request = UNNotificationRequest(identifier: data.id, content: content, trigger: nil) + try await UNUserNotificationCenter.current().add(request) } - let category = UNNotificationCategory( - identifier: "dismissableCategory", - actions: [.init(identifier: "Clear", title: Localizations.clear, options: [.foreground])], - intentIdentifiers: [], - options: [.customDismissAction], - ) - UNUserNotificationCenter.current().setNotificationCategories([category]) - let request = UNNotificationRequest(identifier: data.id, content: content, trigger: nil) - try await UNUserNotificationCenter.current().add(request) if data.userId == userId { // If the request is for the existing account, show the login request view automatically. @@ -400,12 +418,16 @@ class DefaultNotificationService: NotificationService { notificationDismissed: Bool?, notificationTapped: Bool?, ) async -> Bool { + // TODO: PM-33817 Remove this branch (including handleNotificationDismissed and the + // notificationData userInfo key) once all auth request pushes are alert-style and locally + // created notification banners are no longer needed. if let content = message["notificationData"] as? String, let jsonData = content.data(using: .utf8), let loginRequestData = try? JSONDecoder.pascalOrSnakeCaseDecoder.decode( LoginRequestPushNotification.self, from: jsonData, ) { + // Handle taps/dismissals of local notification banners created for silent auth request pushes. if notificationDismissed == true { await handleNotificationDismissed() return true @@ -414,6 +436,25 @@ class DefaultNotificationService: NotificationService { await handleNotificationTapped(loginRequestData) return true } + } else if notificationTapped == true { + // Handle taps of alert push notifications (sent directly by the backend, enriched by the + // notification service extension). These don't carry a `notificationData` key, so the + // push payload is decoded directly. + do { + guard let notificationData = try await decodePayload(message) else { return false } + let loginRequest: LoginRequestNotification = try notificationData.data() + await handleNotificationTapped( + LoginRequestPushNotification( + id: loginRequest.id, + timeoutInMinutes: Constants.loginRequestTimeoutMinutes, + userId: loginRequest.userId, + ), + ) + return true + } catch { + errorReporter.log(error: error) + return false + } } return false } @@ -437,6 +478,12 @@ class DefaultNotificationService: NotificationService { // to that account automatically. if activeAccountId != loginSourceAccount.profile.userId { await delegate?.switchAccountsForLoginRequest(to: loginSourceAccount, showAlert: false) + } else { + // If the request is for the existing account, show the login request view automatically. + guard let id = loginRequestData.id, + let loginRequest = try await authService.getPendingLoginRequest(withId: id).first + else { return } + await delegate?.showLoginRequest(loginRequest) } } catch StateServiceError.noAccounts { let userId = loginRequestData.userId diff --git a/BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift b/BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift index aa050b91a0..e845be543e 100644 --- a/BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift @@ -507,6 +507,57 @@ class NotificationServiceTests: BitwardenTestCase { // swiftlint:disable:this ty XCTAssertEqual(delegate.showLoginRequestRequest, .fixture()) } + /// `messageReceived(_:notificationDismissed:notificationTapped:)` tells the delegate to show the + /// switch account alert for an alert (non-silent) login request for a non-active account, without + /// creating a duplicate local notification. + @MainActor + func test_messageReceived_loginRequest_differentAccount_alertNotification() async throws { + stateService.setIsAuthenticated() + stateService.accounts = [.fixture(), .fixture(profile: .fixture(userId: "differentUser"))] + appIDSettingsStore.appID = "10" + authService.getPendingLoginRequestResult = .success([.fixture()]) + let loginRequestNotification = LoginRequestNotification(id: "requestId", userId: "differentUser") + let notificationData = try JSONEncoder().encode(loginRequestNotification) + nonisolated(unsafe) let message: [AnyHashable: Any] = [ + "aps": ["alert": ["title": "Log In Requested", "body": "Confirm login attempt"]], + "data": [ + "type": NotificationType.authRequest.rawValue, + "payload": String(data: notificationData, encoding: .utf8) ?? "", + ], + ] + + await subject.messageReceived(message, notificationDismissed: nil, notificationTapped: nil) + + XCTAssertEqual(stateService.loginRequest, loginRequestNotification) + XCTAssertEqual(delegate.switchAccountsAccount, .fixture(profile: .fixture(userId: "differentUser"))) + XCTAssertEqual(delegate.switchAccountsShowAlert, true) + } + + /// `messageReceived(_:notificationDismissed:notificationTapped:)` tells the delegate to show the + /// login request for an alert (non-silent) login request for the active account, without creating + /// a duplicate local notification. + @MainActor + func test_messageReceived_loginRequest_sameAccount_alertNotification() async throws { + stateService.setIsAuthenticated() + stateService.accounts = [.fixture()] + appIDSettingsStore.appID = "10" + authService.getPendingLoginRequestResult = .success([.fixture()]) + let loginRequestNotification = LoginRequestNotification(id: "requestId", userId: "1") + let notificationData = try JSONEncoder().encode(loginRequestNotification) + nonisolated(unsafe) let message: [AnyHashable: Any] = [ + "aps": ["alert": ["title": "Log In Requested", "body": "Confirm login attempt"]], + "data": [ + "type": NotificationType.authRequest.rawValue, + "payload": String(data: notificationData, encoding: .utf8) ?? "", + ], + ] + + await subject.messageReceived(message, notificationDismissed: nil, notificationTapped: nil) + + XCTAssertEqual(stateService.loginRequest, loginRequestNotification) + XCTAssertEqual(delegate.showLoginRequestRequest, .fixture()) + } + /// `messageReceived(_:notificationDismissed:notificationTapped:)` handles logout requests and will not route /// to the landing screen if the logged-out account was not the currently active account. @MainActor @@ -681,6 +732,7 @@ class NotificationServiceTests: BitwardenTestCase { // swiftlint:disable:this ty // Set up the mock data. stateService.loginRequest = LoginRequestNotification(id: "1", userId: "2") let loginRequest = LoginRequestPushNotification( + id: nil, timeoutInMinutes: 15, userId: "2", ) @@ -705,6 +757,7 @@ class NotificationServiceTests: BitwardenTestCase { // swiftlint:disable:this ty stateService.loginRequest = LoginRequestNotification(id: "requestId", userId: "1") authService.getPendingLoginRequestResult = .success([.fixture(id: "requestId")]) let loginRequest = LoginRequestPushNotification( + id: nil, timeoutInMinutes: 15, userId: Account.fixture().profile.userId, ) @@ -721,12 +774,61 @@ class NotificationServiceTests: BitwardenTestCase { // swiftlint:disable:this ty XCTAssertEqual(delegate.switchAccountsShowAlert, false) } + /// `messageReceived(_:notificationDismissed:notificationTapped:)` handles taps on alert push + /// notifications by switching accounts silently, matching the local notification tap behavior. + @MainActor + func test_messageReceived_notificationTapped_alertNotification() async throws { + stateService.setIsAuthenticated() + stateService.accounts = [.fixture(), .fixture(profile: .fixture(userId: "differentUser"))] + stateService.activeAccount = .fixture() + appIDSettingsStore.appID = "10" + let loginRequestNotification = LoginRequestNotification(id: "requestId", userId: "differentUser") + let payload = try JSONEncoder().encode(loginRequestNotification) + nonisolated(unsafe) let message: [AnyHashable: Any] = [ + "aps": ["alert": ["title": "Log In Requested", "body": "Confirm login attempt"]], + "data": [ + "type": NotificationType.authRequest.rawValue, + "payload": String(data: payload, encoding: .utf8) ?? "", + ], + ] + + await subject.messageReceived(message, notificationDismissed: nil, notificationTapped: true) + + XCTAssertEqual(delegate.switchAccountsAccount, .fixture(profile: .fixture(userId: "differentUser"))) + XCTAssertEqual(delegate.switchAccountsShowAlert, false) + } + + /// `messageReceived(_:notificationDismissed:notificationTapped:)` shows the login request when + /// an alert push notification is tapped for the active account. + @MainActor + func test_messageReceived_notificationTapped_alertNotification_sameAccount() async throws { + stateService.setIsAuthenticated() + stateService.accounts = [.fixture()] + stateService.activeAccount = .fixture() + appIDSettingsStore.appID = "10" + authService.getPendingLoginRequestResult = .success([.fixture(id: "requestId")]) + let loginRequestNotification = LoginRequestNotification(id: "requestId", userId: "1") + let payload = try JSONEncoder().encode(loginRequestNotification) + nonisolated(unsafe) let message: [AnyHashable: Any] = [ + "aps": ["alert": ["title": "Log In Requested", "body": "Confirm login attempt"]], + "data": [ + "type": NotificationType.authRequest.rawValue, + "payload": String(data: payload, encoding: .utf8) ?? "", + ], + ] + + await subject.messageReceived(message, notificationDismissed: nil, notificationTapped: true) + + XCTAssertEqual(delegate.showLoginRequestRequest, .fixture(id: "requestId")) + } + /// `messageReceived(_:notificationDismissed:notificationTapped:)` handles errors. @MainActor func test_messageReceived_notificationTapped_error() async throws { stateService.accounts = [.fixture()] stateService.getActiveAccountIdError = BitwardenTestError.example let loginRequest = LoginRequestPushNotification( + id: nil, timeoutInMinutes: 15, userId: Account.fixture().profile.userId, ) @@ -745,6 +847,7 @@ class NotificationServiceTests: BitwardenTestCase { // swiftlint:disable:this ty @MainActor func test_messageReceived_notificationTapped_error_accountNotFound() async throws { let loginRequest = LoginRequestPushNotification( + id: nil, timeoutInMinutes: 15, userId: Account.fixture().profile.userId, ) @@ -762,6 +865,28 @@ class NotificationServiceTests: BitwardenTestCase { // swiftlint:disable:this ty ["[Notification] Notification tapped for login request but account (\(userId)) not found"], ) } + + /// `messageReceived(_:notificationDismissed:notificationTapped:)` shows the login request when + /// a local notification banner is tapped for the active account. + @MainActor + func test_messageReceived_notificationTapped_sameAccount() async throws { + stateService.accounts = [.fixture()] + stateService.activeAccount = .fixture() + authService.getPendingLoginRequestResult = .success([.fixture(id: "requestId")]) + let loginRequest = LoginRequestPushNotification( + id: "requestId", + timeoutInMinutes: 15, + userId: Account.fixture().profile.userId, + ) + let testData = try JSONEncoder().encode(loginRequest) + nonisolated(unsafe) let message: [AnyHashable: Any] = [ + "notificationData": String(data: testData, encoding: .utf8) ?? "", + ] + + await subject.messageReceived(message, notificationDismissed: nil, notificationTapped: true) + + XCTAssertEqual(delegate.showLoginRequestRequest, .fixture(id: "requestId")) + } } // MARK: - MockNotificationServiceDelegate