Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 72 additions & 25 deletions BitwardenShared/Core/Platform/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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
Expand Down
Loading