From 1f0b3ada42920652417ca58535051a771029dbb0 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Fri, 13 Mar 2026 10:32:49 -0500 Subject: [PATCH 1/3] [PM-33569] feat: Add notification service extension to update auth request notifications --- ...itwardenNotificationExtension.entitlements | 10 ++ .../Application/Support/Info.plist | 107 +++++++++++++ .../NotificationServiceExtension.swift | 31 ++++ .../NotificationExtensionHelper.swift | 121 +++++++++++++++ .../NotificationExtensionHelperTests.swift | 141 ++++++++++++++++++ .../BitwardenNotificationExtension.xcconfig | 5 + Configs/Common-bwpm.xcconfig | 1 + project-pm.yml | 22 +++ 8 files changed, 438 insertions(+) create mode 100644 BitwardenNotificationExtension/Application/Support/BitwardenNotificationExtension.entitlements create mode 100644 BitwardenNotificationExtension/Application/Support/Info.plist create mode 100644 BitwardenNotificationExtension/NotificationServiceExtension.swift create mode 100644 BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift create mode 100644 BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift create mode 100644 Configs/BitwardenNotificationExtension.xcconfig diff --git a/BitwardenNotificationExtension/Application/Support/BitwardenNotificationExtension.entitlements b/BitwardenNotificationExtension/Application/Support/BitwardenNotificationExtension.entitlements new file mode 100644 index 0000000000..5589adac97 --- /dev/null +++ b/BitwardenNotificationExtension/Application/Support/BitwardenNotificationExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.$(BASE_BUNDLE_ID) + + + diff --git a/BitwardenNotificationExtension/Application/Support/Info.plist b/BitwardenNotificationExtension/Application/Support/Info.plist new file mode 100644 index 0000000000..885192eb65 --- /dev/null +++ b/BitwardenNotificationExtension/Application/Support/Info.plist @@ -0,0 +1,107 @@ + + + + + BitwardenAppIdentifier + $(BASE_BUNDLE_ID) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Bitwarden + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + af + ar + az + be + bg + bn + bs + ca + cs + cy + da + de + el + en + en-GB + en-IN + es + et + eu + fa + fi + fil + fr + gl + he + hi + hr + hu + id + it + ja + ka + kn + ko + lt + lv + ml + mr + my + nb + ne + nl + nn-NO + or + pl + pt + pt-BR + ro + ru + si + sk + sl + sr + sv + ta + te + th + tr + uk + vi + zh-Hans + zh-Hant + + CFBundleName + Bitwarden Notification + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + ITSEncryptionExportComplianceCode + ecf076d3-4824-4d7b-b716-2a9a47d7d296 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationServiceExtension + + UIRequiredDeviceCapabilities + + arm64 + + + + diff --git a/BitwardenNotificationExtension/NotificationServiceExtension.swift b/BitwardenNotificationExtension/NotificationServiceExtension.swift new file mode 100644 index 0000000000..65b96a7778 --- /dev/null +++ b/BitwardenNotificationExtension/NotificationServiceExtension.swift @@ -0,0 +1,31 @@ +import BitwardenShared +import OSLog +import UserNotifications + +// MARK: - NotificationServiceExtension + +/// The notification service extension entry point, responsible for intercepting incoming alert +/// push notifications and updating their content before delivery. +/// +/// This class is intentionally thin — all decoding and state-lookup logic lives in +/// `DefaultNotificationExtensionHelper` (in `BitwardenShared`) where it is fully testable. +/// +class NotificationServiceExtension: UNNotificationServiceExtension { + // MARK: UNNotificationServiceExtension + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void, + ) { + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + Logger.appExtension.error("Failed to cast UNNotificationContent to UNMutableNotificationContent") + contentHandler(request.content) + return + } + + Task { + let updatedContent = await DefaultNotificationExtensionHelper().processNotification(content: content) + contentHandler(updatedContent) + } + } +} diff --git a/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift new file mode 100644 index 0000000000..9c8f135ff7 --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift @@ -0,0 +1,121 @@ +import BitwardenKit +import BitwardenResources +import Foundation +import UserNotifications + +// MARK: - NotificationExtensionHelper + +/// A protocol for a helper that processes notification content within a notification service extension. +/// +public protocol NotificationExtensionHelper { // sourcery: AutoMockable + /// Processes a notification, updating the content as appropriate based on its type. + /// + /// - Parameter content: The mutable notification content to update. + /// - Returns: The updated notification content, or the original content unchanged if the + /// payload cannot be decoded or no update applies. + /// + func processNotification( + content: UNMutableNotificationContent, + ) async -> UNMutableNotificationContent +} + +// MARK: - DefaultNotificationExtensionHelper + +/// The default implementation of `NotificationExtensionHelper`. +/// +public class DefaultNotificationExtensionHelper: NotificationExtensionHelper { + // MARK: Properties + + /// The store used to read persisted app settings. + private let appSettingsStore: AppSettingsStore + + /// The reporter used to log non-fatal errors. + private let errorReporter: ErrorReporter + + // MARK: Initialization + + /// Initializes a `DefaultNotificationExtensionHelper` with an explicit settings store + /// and error reporter. + /// + /// - Parameters: + /// - appSettingsStore: The store used to read persisted app settings. + /// - errorReporter: The reporter used to log non-fatal errors. + /// + init(appSettingsStore: AppSettingsStore, errorReporter: ErrorReporter) { + self.appSettingsStore = appSettingsStore + self.errorReporter = errorReporter + + Resources.initialLanguageCode = appSettingsStore.appLocale ?? Bundle.main.preferredLocalizations.first + } + + /// Initializes a `DefaultNotificationExtensionHelper` using the shared app group `UserDefaults` + /// and an `OSLogErrorReporter`. + /// + public convenience init() { + let userDefaults = UserDefaults(suiteName: Bundle.main.groupIdentifier)! + self.init( + appSettingsStore: DefaultAppSettingsStore(userDefaults: userDefaults), + errorReporter: OSLogErrorReporter(), + ) + } + + // MARK: NotificationExtensionHelper + + public func processNotification(content: UNMutableNotificationContent) async -> UNMutableNotificationContent { + do { + guard let notificationData = try decodePushNotificationData(from: content.userInfo), + let type = notificationData.type + else { + return content + } + + switch type { + case .authRequest: + return try handleAuthRequest(notificationData, content: content) + default: + return content + } + } catch { + errorReporter.log(error: error) + return content + } + } + + // MARK: Private + + /// Decodes a `PushNotificationData` from a notification's `userInfo` dictionary. + /// + /// - Parameter userInfo: The notification's `userInfo` dictionary. + /// - Returns: The decoded `PushNotificationData`, or `nil` if the `"data"` key is absent. + /// - Throws: An error if JSON serialization or decoding fails. + /// + private func decodePushNotificationData(from userInfo: [AnyHashable: Any]) throws -> PushNotificationData? { + guard let messageContent = userInfo["data"] as? [AnyHashable: Any] else { return nil } + let jsonData = try JSONSerialization.data(withJSONObject: messageContent) + return try JSONDecoder().decode(PushNotificationData.self, from: jsonData) + } + + /// Handles an auth request notification, updating the body with the requesting user's email + /// if found in the accounts store. + /// + /// - Parameters: + /// - notificationData: The decoded push notification data. + /// - content: The mutable notification content to update. + /// - Returns: The updated content, or the original content unchanged if the user is not found. + /// - Throws: An error if the login request payload cannot be decoded. + /// + private func handleAuthRequest( + _ notificationData: PushNotificationData, + content: UNMutableNotificationContent, + ) throws -> UNMutableNotificationContent { + let loginRequest: LoginRequestNotification = try notificationData.data() + guard let email = appSettingsStore.state?.accounts[loginRequest.userId]?.profile.email else { + return content + } + + content.title = Localizations.logInRequested + content.body = Localizations.confimLogInAttempForX(email) + + return content + } +} diff --git a/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift new file mode 100644 index 0000000000..1d5ef171eb --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift @@ -0,0 +1,141 @@ +import BitwardenKitMocks +import BitwardenResources +import Testing +import UserNotifications + +@testable import BitwardenShared +@testable import BitwardenSharedMocks + +// MARK: - NotificationExtensionHelperTests + +@MainActor +struct NotificationExtensionHelperTests { + // MARK: Properties + + var appSettingsStore: MockAppSettingsStore + var errorReporter: MockErrorReporter + var subject: DefaultNotificationExtensionHelper + + // MARK: Initialization + + init() { + appSettingsStore = MockAppSettingsStore() + errorReporter = MockErrorReporter() + subject = DefaultNotificationExtensionHelper( + appSettingsStore: appSettingsStore, + errorReporter: errorReporter, + ) + } + + // MARK: Tests + + /// `processNotification(content:)` updates the title and body with the user's email when the + /// payload is valid and the user is found in the accounts store. + @Test + func processNotification_authRequest_updatesContent() async throws { + let userId = "user-1" + let email = "user@bitwarden.com" + let account = Account.fixture(profile: .fixture(email: email, userId: userId)) + appSettingsStore.state = State(accounts: [userId: account], activeUserId: userId) + + let payloadString = try jsonString(LoginRequestNotification(id: "request-1", userId: userId)) + let content = makeContent(type: 15, payload: payloadString) + + let result = await subject.processNotification(content: content) + + #expect(result.title == Localizations.logInRequested) + #expect(result.body == Localizations.confimLogInAttempForX(email)) + #expect(errorReporter.errors.isEmpty) + } + + /// `processNotification(content:)` returns the original content unchanged when the user ID + /// in the payload does not match any known account. + @Test + func processNotification_authRequest_returnsOriginalContent_whenUserNotFound() async throws { + appSettingsStore.state = State(accounts: [:], activeUserId: nil) + + let payloadString = try jsonString(LoginRequestNotification(id: "request-1", userId: "unknown-user")) + let content = makeContent(type: 15, payload: payloadString) + let originalBody = content.body + + let result = await subject.processNotification(content: content) + + #expect(result.body == originalBody) + #expect(errorReporter.errors.isEmpty) + } + + /// `processNotification(content:)` returns the original content unchanged and logs an error + /// when the notification payload cannot be decoded. + @Test + func processNotification_logsError_whenPayloadMalformed() async throws { + let content = makeContent(type: 15, payload: "not valid json {{{") + let originalBody = content.body + + let result = await subject.processNotification(content: content) + + #expect(result.body == originalBody) + #expect(errorReporter.errors.count == 1) + let error = try #require(errorReporter.errors.first as? PushNotificationDataError) + guard case let .payloadDecodingFailed(type, _) = error else { + Issue.record("Expected payloadDecodingFailed, got \(error)") + return + } + #expect(type == .authRequest) + } + + /// `processNotification(content:)` returns the original content unchanged when the notification + /// has no `"data"` key in `userInfo` (e.g. a non-Bitwarden notification). + @Test + func processNotification_returnsOriginalContent_whenNoDataKey() async throws { + let content = UNMutableNotificationContent() + content.body = "Some other notification" + + let result = await subject.processNotification(content: content) + + #expect(result.body == "Some other notification") + #expect(errorReporter.errors.isEmpty) + } + + /// `processNotification(content:)` returns the original content unchanged when the notification + /// type is not handled. + @Test + func processNotification_returnsOriginalContent_whenTypeNotHandled() async throws { + let userId = "user-1" + let account = Account.fixture(profile: .fixture(email: "user@bitwarden.com", userId: userId)) + appSettingsStore.state = State(accounts: [userId: account], activeUserId: userId) + + let payloadString = try jsonString(LoginRequestNotification(id: "request-1", userId: userId)) + // syncCipherUpdate = type 0, not handled by the extension + let content = makeContent(type: 0, payload: payloadString) + let originalBody = content.body + + let result = await subject.processNotification(content: content) + + #expect(result.body == originalBody) + #expect(errorReporter.errors.isEmpty) + } + + // MARK: Private + + /// Encodes a `Codable` value to a compact JSON string. + private func jsonString( + _ value: T, + sourceLocation: SourceLocation = #_sourceLocation, + ) throws -> String { + let data = try JSONEncoder().encode(value) + return try #require(String(bytes: data, encoding: .utf8), sourceLocation: sourceLocation) + } + + /// Creates a `UNMutableNotificationContent` with the given notification type and payload. + private func makeContent(type: Int, payload: String) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.body = "Confirm login attempt" + content.userInfo = [ + "data": [ + "type": type, + "payload": payload, + ] as [String: Any], + ] + return content + } +} diff --git a/Configs/BitwardenNotificationExtension.xcconfig b/Configs/BitwardenNotificationExtension.xcconfig new file mode 100644 index 0000000000..f8bb21aa95 --- /dev/null +++ b/Configs/BitwardenNotificationExtension.xcconfig @@ -0,0 +1,5 @@ +#include "./Common-bwpm.xcconfig" +#include? "./Local-bwpm.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).notification +PROVISIONING_PROFILE_SPECIFIER = $(PROVISIONING_PROFILE_SPECIFIER_NOTIFICATION_EXTENSION) diff --git a/Configs/Common-bwpm.xcconfig b/Configs/Common-bwpm.xcconfig index cfca9ffac7..5a0e4b32ee 100644 --- a/Configs/Common-bwpm.xcconfig +++ b/Configs/Common-bwpm.xcconfig @@ -19,6 +19,7 @@ BMPW_BUNDLE_DISPLAY_NAME = Bitwarden // PROVISIONING_PROFILE_SPECIFIER = // PROVISIONING_PROFILE_SPECIFIER_ACTION_EXTENSION = // PROVISIONING_PROFILE_SPECIFIER_AUTOFILL_EXTENSION = +// PROVISIONING_PROFILE_SPECIFIER_NOTIFICATION_EXTENSION = // PROVISIONING_PROFILE_SPECIFIER_SHARE_EXTENSION = // PROVISIONING_PROFILE_SPECIFIER_WATCH_APP = // PROVISIONING_PROFILE_SPECIFIER_WATCH_WIDGET_EXTENSION = diff --git a/project-pm.yml b/project-pm.yml index 8e64c9e57e..41b3ce31db 100644 --- a/project-pm.yml +++ b/project-pm.yml @@ -94,6 +94,10 @@ schemes: - path: TestPlans/Bitwarden-Default.xctestplan defaultPlan: true - path: TestPlans/Bitwarden-Unit.xctestplan + BitwardenNotificationExtension: + build: + targets: + BitwardenNotificationExtension: all BitwardenShareExtension: build: targets: @@ -170,6 +174,7 @@ targets: - target: BitwardenShared - target: BitwardenActionExtension - target: BitwardenAutoFillExtension + - target: BitwardenNotificationExtension - target: BitwardenShareExtension - target: BitwardenWatchApp - target: BitwardenKit/AuthenticatorBridgeKit @@ -294,6 +299,23 @@ targets: - target: BitwardenKit/TestHelpers randomExecutionOrder: true + BitwardenNotificationExtension: + type: app-extension + platform: iOS + configFiles: + Debug: Configs/BitwardenNotificationExtension.xcconfig + Release: Configs/BitwardenNotificationExtension.xcconfig + settings: + base: + CODE_SIGN_ENTITLEMENTS: BitwardenNotificationExtension/Application/Support/BitwardenNotificationExtension.entitlements + INFOPLIST_FILE: BitwardenNotificationExtension/Application/Support/Info.plist + templates: + - CommonTarget + templateAttributes: + sourcesPath: BitwardenNotificationExtension + dependencies: + - target: BitwardenShared + BitwardenShareExtension: type: app-extension platform: iOS From 184425d55c2af82135dfa774b8abbc184180035a Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Fri, 13 Mar 2026 10:49:20 -0500 Subject: [PATCH 2/3] [PM-33569] Update docs and CI for new target --- .github/CODEOWNERS | 1 + .github/label-pr.json | 1 + .github/workflows/ci-bwa.yml | 1 + .github/workflows/test-bwa.yml | 1 + Docs/Architecture.md | 1 + fastlane/.env.bwpm_beta | 1 + fastlane/.env.bwpm_prod | 1 + fastlane/Fastfile | 3 +++ 8 files changed, 10 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7568d82a1d..a560b0ff5f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,6 +21,7 @@ # Platform # Bitwarden @bitwarden/team-platform-dev +# BitwardenNotificationExtension @bitwarden/team-platform-dev # BitwardenShareExtension @bitwarden/team-platform-dev # BitwardenShared/Core/Platform @bitwarden/team-platform-dev # BitwardenShared/UI/Platform @bitwarden/team-platform-dev diff --git a/.github/label-pr.json b/.github/label-pr.json index 38b4c8372e..49812ac0f2 100644 --- a/.github/label-pr.json +++ b/.github/label-pr.json @@ -25,6 +25,7 @@ "Bitwarden/", "BitwardenActionExtension/", "BitwardenAutoFillExtension/", + "BitwardenNotificationExtension/", "BitwardenShareExtension/", "BitwardenShared/", "BitwardenWatchApp/", diff --git a/.github/workflows/ci-bwa.yml b/.github/workflows/ci-bwa.yml index badc5b43a2..303e1306f1 100644 --- a/.github/workflows/ci-bwa.yml +++ b/.github/workflows/ci-bwa.yml @@ -10,6 +10,7 @@ on: - Bitwarden/** - BitwardenActionExtension/** - BitwardenAutoFillExtension/** + - BitwardenNotificationExtension/** - BitwardenShared/** - BitwardenShareExtension/** - BitwardenWatchApp/** diff --git a/.github/workflows/test-bwa.yml b/.github/workflows/test-bwa.yml index 700da35d7a..02952282a4 100644 --- a/.github/workflows/test-bwa.yml +++ b/.github/workflows/test-bwa.yml @@ -12,6 +12,7 @@ on: - Bitwarden/** - BitwardenActionExtension/** - BitwardenAutoFillExtension/** + - BitwardenNotificationExtension/** - BitwardenShared/** - BitwardenShareExtension/** - BitwardenWatchApp/** diff --git a/Docs/Architecture.md b/Docs/Architecture.md index b2f3896e09..45f85cbe98 100644 --- a/Docs/Architecture.md +++ b/Docs/Architecture.md @@ -38,6 +38,7 @@ The iOS repository contains two main apps: Bitwarden Password Manager and Bitwar - `Bitwarden`: The main iOS Password Manager app. - `BitwardenActionExtension`: An [Action extension](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Action.html) that can be accessed via the system share sheet "Autofill with Bitwarden" option. - `BitwardenAutoFillExtension`: An AutoFill Credential Provider extension which allows Bitwarden to offer up credentials for [Password AutoFill](https://developer.apple.com/documentation/security/password_autofill/). +- `BitwardenNotificationExtension`: A [UNNotificationServiceExtension](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension) that is used to update the content of push notifications. - `BitwardenShareExtension`: A [Share extension](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Share.html) that allows creating text or file sends via the system share sheet. - `BitwardenWatchApp`: The companion watchOS app. - `BitwardenShared`: The main Password Manager framework containing the Core and UI layers shared between the app and extensions. diff --git a/fastlane/.env.bwpm_beta b/fastlane/.env.bwpm_beta index ffb012c2d2..4f3736359d 100644 --- a/fastlane/.env.bwpm_beta +++ b/fastlane/.env.bwpm_beta @@ -6,6 +6,7 @@ _PROVISIONING_PROFILES=" dist_beta_autofill.mobileprovision, dist_beta_bitwarden.mobileprovision, dist_beta_extension.mobileprovision, + dist_beta_notification_extension.mobileprovision, dist_beta_share_extension.mobileprovision, dist_beta_bitwarden_watch_app.mobileprovision, dist_beta_bitwarden_watch_app_extension.mobileprovision, diff --git a/fastlane/.env.bwpm_prod b/fastlane/.env.bwpm_prod index ac2ecb1186..24bdc2a5e4 100644 --- a/fastlane/.env.bwpm_prod +++ b/fastlane/.env.bwpm_prod @@ -6,6 +6,7 @@ _PROVISIONING_PROFILES=" dist_autofill.mobileprovision, dist_bitwarden.mobileprovision, dist_extension.mobileprovision, + dist_notification_extension.mobileprovision, dist_share_extension.mobileprovision, dist_bitwarden_watch_app.mobileprovision, dist_bitwarden_watch_app_extension.mobileprovision, diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 77ca433fd2..07d538c1db 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -312,6 +312,7 @@ platform :ios do |options| "PROVISIONING_PROFILE_SPECIFIER" => "#{profile_prefix} Bitwarden", "PROVISIONING_PROFILE_SPECIFIER_ACTION_EXTENSION" => "#{profile_prefix} Extension", "PROVISIONING_PROFILE_SPECIFIER_AUTOFILL_EXTENSION" => "#{profile_prefix} Autofill", + "PROVISIONING_PROFILE_SPECIFIER_NOTIFICATION_EXTENSION" => "#{profile_prefix} Notification Extension", "PROVISIONING_PROFILE_SPECIFIER_SHARE_EXTENSION" => "#{profile_prefix} Share Extension", "PROVISIONING_PROFILE_SPECIFIER_WATCH_APP" => "#{profile_prefix} Bitwarden Watch App", "PROVISIONING_PROFILE_SPECIFIER_WATCH_WIDGET_EXTENSION" => "#{profile_prefix} Bitwarden Watch Widget Extension", @@ -335,6 +336,8 @@ platform :ios do |options| #{profile_prefix} Extension #{bundle_id}.autofill #{profile_prefix} Autofill + #{bundle_id}.notification + #{profile_prefix} Notification Extension #{bundle_id}.share-extension #{profile_prefix} Share Extension #{bundle_id}.watchkitapp From 6a6015fa21c709a3e307579701ebb8638780c223 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Mon, 16 Mar 2026 16:37:54 -0500 Subject: [PATCH 3/3] [PM-33569] Add PushNotificationData initializer from a notification's userInfo dictionary --- .../Models/Domain/PushNotificationData.swift | 25 +++++++++ .../Domain/PushNotificationDataTests.swift | 56 +++++++++++++++++++ .../NotificationExtensionHelper.swift | 17 +----- .../NotificationExtensionHelperTests.swift | 7 ++- .../Services/NotificationService.swift | 5 +- 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift b/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift index d1010c6a05..293599ca7e 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift +++ b/BitwardenShared/Core/Platform/Models/Domain/PushNotificationData.swift @@ -34,11 +34,33 @@ struct PushNotificationData: Codable { } } +// MARK: - PushNotificationData + userInfo + +extension PushNotificationData { + /// Decodes a `PushNotificationData` from a notification's `userInfo` dictionary. + /// + /// - Parameter userInfo: The notification's `userInfo` dictionary. + /// - Returns: The decoded `PushNotificationData`. + /// - Throws: `PushNotificationDataError.missingDataDictionary` if the `"data"` key is absent, + /// or an error if JSON serialization or decoding fails. + /// + init(userInfo: [AnyHashable: Any]) throws { + guard let messageContent = userInfo["data"] as? [AnyHashable: Any] else { + throw PushNotificationDataError.missingDataDictionary + } + let jsonData = try JSONSerialization.data(withJSONObject: messageContent) + self = try JSONDecoder().decode(PushNotificationData.self, from: jsonData) + } +} + // MARK: - PushNotificationDataError /// An error thrown when a push notification payload cannot be decoded. /// enum PushNotificationDataError: Error, CustomNSError { + /// Thrown when the notification's `userInfo` dictionary does not contain a `"data"` key. + case missingDataDictionary + /// Thrown when the push notification payload cannot be decoded into the expected type. case payloadDecodingFailed(type: NotificationType?, underlyingError: Error) @@ -47,6 +69,7 @@ enum PushNotificationDataError: Error, CustomNSError { // incremented integer. This ensures the code for existing errors doesn't change. switch self { case .payloadDecodingFailed: 1 + case .missingDataDictionary: 2 } } @@ -56,6 +79,8 @@ enum PushNotificationDataError: Error, CustomNSError { ] switch self { + case .missingDataDictionary: + break case let .payloadDecodingFailed(type, underlyingError): userInfo[NSUnderlyingErrorKey] = underlyingError if let type { diff --git a/BitwardenShared/Core/Platform/Models/Domain/PushNotificationDataTests.swift b/BitwardenShared/Core/Platform/Models/Domain/PushNotificationDataTests.swift index b5b908e64e..12299f9ccc 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/PushNotificationDataTests.swift +++ b/BitwardenShared/Core/Platform/Models/Domain/PushNotificationDataTests.swift @@ -3,6 +3,62 @@ import XCTest @testable import BitwardenShared class PushNotificationDataTests: BitwardenTestCase { + // MARK: init(userInfo:) Tests + + /// `init(userInfo:)` successfully decodes a `PushNotificationData` from a valid `userInfo` dictionary. + func test_initUserInfo() throws { + let userInfo: [AnyHashable: Any] = [ + "data": [ + "contextId": "context-123", + "payload": "test-payload", + "type": 1, + ] as [AnyHashable: Any], + ] + + let subject = try PushNotificationData(userInfo: userInfo) + + XCTAssertEqual(subject.contextId, "context-123") + XCTAssertEqual(subject.payload, "test-payload") + XCTAssertEqual(subject.type, .syncCipherCreate) + } + + /// `init(userInfo:)` throws `missingDataDictionary` when the `userInfo` dictionary is empty. + func test_initUserInfo_emptyDictionary() { + XCTAssertThrowsError(try PushNotificationData(userInfo: [:])) { error in + guard case PushNotificationDataError.missingDataDictionary = error else { + XCTFail("Expected PushNotificationDataError.missingDataDictionary, got \(error)") + return + } + } + } + + /// `init(userInfo:)` throws a decoding error when the `"data"` value cannot be decoded. + func test_initUserInfo_invalidData() { + let userInfo: [AnyHashable: Any] = [ + "data": [ + "type": "not-a-number", + ] as [AnyHashable: Any], + ] + + XCTAssertThrowsError(try PushNotificationData(userInfo: userInfo)) { error in + XCTAssertTrue(error is DecodingError) + } + } + + /// `init(userInfo:)` throws `missingDataDictionary` when the `"data"` key is absent. + func test_initUserInfo_missingDataKey() { + let userInfo: [AnyHashable: Any] = ["other": "value"] + + XCTAssertThrowsError(try PushNotificationData(userInfo: userInfo)) { error in + guard case PushNotificationDataError.missingDataDictionary = error else { + XCTFail("Expected PushNotificationDataError.missingDataDictionary, got \(error)") + return + } + } + } + + // MARK: data Tests + /// `data` decodes the payload as expected. func test_data() throws { let subject = PushNotificationData( diff --git a/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift index 9c8f135ff7..eb3458e0c2 100644 --- a/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift +++ b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelper.swift @@ -63,9 +63,8 @@ public class DefaultNotificationExtensionHelper: NotificationExtensionHelper { public func processNotification(content: UNMutableNotificationContent) async -> UNMutableNotificationContent { do { - guard let notificationData = try decodePushNotificationData(from: content.userInfo), - let type = notificationData.type - else { + let notificationData = try PushNotificationData(userInfo: content.userInfo) + guard let type = notificationData.type else { return content } @@ -83,18 +82,6 @@ public class DefaultNotificationExtensionHelper: NotificationExtensionHelper { // MARK: Private - /// Decodes a `PushNotificationData` from a notification's `userInfo` dictionary. - /// - /// - Parameter userInfo: The notification's `userInfo` dictionary. - /// - Returns: The decoded `PushNotificationData`, or `nil` if the `"data"` key is absent. - /// - Throws: An error if JSON serialization or decoding fails. - /// - private func decodePushNotificationData(from userInfo: [AnyHashable: Any]) throws -> PushNotificationData? { - guard let messageContent = userInfo["data"] as? [AnyHashable: Any] else { return nil } - let jsonData = try JSONSerialization.data(withJSONObject: messageContent) - return try JSONDecoder().decode(PushNotificationData.self, from: jsonData) - } - /// Handles an auth request notification, updating the body with the requesting user's email /// if found in the accounts store. /// diff --git a/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift index 1d5ef171eb..5ccab67fdc 100644 --- a/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift +++ b/BitwardenShared/Core/Platform/Services/NotificationExtensionHelperTests.swift @@ -93,7 +93,12 @@ struct NotificationExtensionHelperTests { let result = await subject.processNotification(content: content) #expect(result.body == "Some other notification") - #expect(errorReporter.errors.isEmpty) + #expect(errorReporter.errors.count == 1) + let error = try #require(errorReporter.errors.first as? PushNotificationDataError) + guard case .missingDataDictionary = error else { + Issue.record("Expected missingDataDictionary, got \(error)") + return + } } /// `processNotification(content:)` returns the original content unchanged when the notification diff --git a/BitwardenShared/Core/Platform/Services/NotificationService.swift b/BitwardenShared/Core/Platform/Services/NotificationService.swift index e21169096d..99298d5487 100644 --- a/BitwardenShared/Core/Platform/Services/NotificationService.swift +++ b/BitwardenShared/Core/Platform/Services/NotificationService.swift @@ -311,10 +311,7 @@ class DefaultNotificationService: NotificationService { /// private func decodePayload(_ message: [AnyHashable: Any]) async throws -> PushNotificationData? { // Decode the content of the message. - guard let messageContent = message["data"] as? [AnyHashable: Any] - else { return nil } - let jsonData = try JSONSerialization.data(withJSONObject: messageContent) - let notificationData = try JSONDecoder().decode(PushNotificationData.self, from: jsonData) + let notificationData = try PushNotificationData(userInfo: message) // Verify that the payload is not empty and that the context is correct. let appId = await appIdService.getOrCreateAppId()