mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -06:00
402 lines
16 KiB
Swift
402 lines
16 KiB
Swift
import BitwardenKit
|
|
import BitwardenResources
|
|
import Foundation
|
|
import OSLog
|
|
import UserNotifications
|
|
|
|
// MARK: - NotificationService
|
|
|
|
/// A protocol for a service that handles app notifications.
|
|
///
|
|
protocol NotificationService {
|
|
/// Decodes and saves the push notification token after the device has successfully registered for push
|
|
/// notifications.
|
|
///
|
|
/// - Parameter tokenData: The data of the push notification token.
|
|
///
|
|
func didRegister(withToken tokenData: Data) async
|
|
|
|
/// Processes any messages received by the application.
|
|
///
|
|
/// - Parameters:
|
|
/// - message: The content of the push notification.
|
|
/// - notificationDismissed: `true` if a notification banner has been dismissed.
|
|
/// - notificationTapped: `true` if a notification banner has been tapped.
|
|
///
|
|
func messageReceived(
|
|
_ message: [AnyHashable: Any],
|
|
notificationDismissed: Bool?,
|
|
notificationTapped: Bool?,
|
|
) async
|
|
|
|
/// Gets the notification authorization for the device.
|
|
///
|
|
/// - Returns: The current device UNAuthorizationStatus.
|
|
///
|
|
func notificationAuthorization() async -> UNAuthorizationStatus
|
|
|
|
/// Requests notification authotrization.
|
|
///
|
|
/// - Parameter options: The `UNAuthorizationOptions` to request.
|
|
/// - Returns: A bool indicating the status.
|
|
///
|
|
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
|
|
|
/// Set the delegate for the `NotificationService`.
|
|
///
|
|
/// - Parameter delegate: The delegate.
|
|
///
|
|
func setDelegate(_ delegate: NotificationServiceDelegate?)
|
|
}
|
|
|
|
// MARK: - NotificationServiceDelegate
|
|
|
|
/// The delegate to handle login request actions originating from notifications.
|
|
///
|
|
@MainActor
|
|
protocol NotificationServiceDelegate: AnyObject {
|
|
/// Users are logged out, route to landing page.
|
|
///
|
|
func routeToLanding() async
|
|
|
|
/// Show the login request.
|
|
///
|
|
/// - Parameter loginRequest: The login request.
|
|
///
|
|
func showLoginRequest(_ loginRequest: LoginRequest)
|
|
|
|
/// Switch the active account in order to show the login request, prompting the user if necessary.
|
|
///
|
|
/// - Parameters:
|
|
/// - account: The account associated with the login request.
|
|
/// - showAlert: Whether to show the alert or simply switch the account.
|
|
///
|
|
func switchAccountsForLoginRequest(to account: Account, showAlert: Bool) async
|
|
}
|
|
|
|
// MARK: - DefaultNotificationService
|
|
|
|
/// The default implementation of `NotificationService`.
|
|
///
|
|
class DefaultNotificationService: NotificationService {
|
|
// MARK: Properties
|
|
|
|
/// The delegate to handle login request actions originating from notifications.
|
|
private weak var delegate: NotificationServiceDelegate?
|
|
|
|
/// The service used by the application to manage the app's ID.
|
|
private let appIdService: AppIdService
|
|
|
|
/// The repository used by the application to manage auth data for the UI layer.
|
|
private let authRepository: AuthRepository
|
|
|
|
/// The service used by the application to handle authentication tasks.
|
|
private let authService: AuthService
|
|
|
|
/// The service to get server-specified configuration.
|
|
private let configService: ConfigService
|
|
|
|
/// The service used by the application to report non-fatal errors.
|
|
private let errorReporter: ErrorReporter
|
|
|
|
/// The API service used to make notification requests.
|
|
private let notificationAPIService: NotificationAPIService
|
|
|
|
/// The API service used to refresh tokens.
|
|
private let refreshableApiService: RefreshableAPIService
|
|
|
|
/// The service used by the application to manage account state.
|
|
private let stateService: StateService
|
|
|
|
/// The service used to handle syncing vault data with the API.
|
|
private let syncService: SyncService
|
|
|
|
// MARK: Initialization
|
|
|
|
/// Initializes the `DefaultNotificationService`.
|
|
///
|
|
/// - Parameters:
|
|
/// - appIdService: The service used by the application to manage the app's ID.
|
|
/// - authRepository: The repository used by the application to manage auth data for the UI layer.
|
|
/// - authService: The service used by the application to handle authentication tasks.
|
|
/// - configService: The service to get server-specified configuration.
|
|
/// - errorReporter: The service used by the application to report non-fatal errors.
|
|
/// - notificationAPIService: The API service used to make notification requests.
|
|
/// - refreshableApiService: The API service used to refresh tokens.
|
|
/// - stateService: The service used by the application to manage account state.
|
|
/// - syncService: The service used to handle syncing vault data with the API.
|
|
init(
|
|
appIdService: AppIdService,
|
|
authRepository: AuthRepository,
|
|
authService: AuthService,
|
|
configService: ConfigService,
|
|
errorReporter: ErrorReporter,
|
|
notificationAPIService: NotificationAPIService,
|
|
refreshableApiService: RefreshableAPIService,
|
|
stateService: StateService,
|
|
syncService: SyncService,
|
|
) {
|
|
self.appIdService = appIdService
|
|
self.authRepository = authRepository
|
|
self.authService = authService
|
|
self.configService = configService
|
|
self.errorReporter = errorReporter
|
|
self.notificationAPIService = notificationAPIService
|
|
self.refreshableApiService = refreshableApiService
|
|
self.stateService = stateService
|
|
self.syncService = syncService
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
func setDelegate(_ delegate: NotificationServiceDelegate?) {
|
|
self.delegate = delegate
|
|
}
|
|
|
|
func didRegister(withToken tokenData: Data) async {
|
|
do {
|
|
// Don't proceed unless the user is authenticated.
|
|
guard try await stateService.isAuthenticated() else { return }
|
|
|
|
// Get the app ID.
|
|
let appId = await appIdService.getOrCreateAppId()
|
|
|
|
// Decode and save the push notification token.
|
|
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
|
try await notificationAPIService.savePushNotificationToken(for: appId, token: token)
|
|
|
|
// Record the date that the token was saved.
|
|
try await stateService.setNotificationsLastRegistrationDate(Date())
|
|
} catch {
|
|
errorReporter.log(error: error)
|
|
}
|
|
}
|
|
|
|
func messageReceived( // swiftlint:disable:this function_body_length cyclomatic_complexity
|
|
_ message: [AnyHashable: Any],
|
|
notificationDismissed: Bool?,
|
|
notificationTapped: Bool?,
|
|
) async {
|
|
do {
|
|
// First attempt to decode the message as a response.
|
|
if await handleLoginRequestResponse(
|
|
message,
|
|
notificationDismissed: notificationDismissed,
|
|
notificationTapped: notificationTapped,
|
|
) { return }
|
|
|
|
// Proceed to treat the message as new notification.
|
|
guard try await stateService.isAuthenticated(),
|
|
let notificationData = try await decodePayload(message),
|
|
let type = notificationData.type
|
|
else { return }
|
|
let userId = try await stateService.getActiveAccountId()
|
|
|
|
Logger.application.debug("Notification received: \(message)")
|
|
|
|
// Handle the notification according to the type of data.
|
|
switch type {
|
|
case .syncCipherCreate,
|
|
.syncCipherUpdate:
|
|
if let data: SyncCipherNotification = notificationData.data(), data.userId == userId {
|
|
try await syncService.fetchUpsertSyncCipher(data: data)
|
|
}
|
|
case .syncFolderCreate,
|
|
.syncFolderUpdate:
|
|
if let data: SyncFolderNotification = notificationData.data(), data.userId == userId {
|
|
try await syncService.fetchUpsertSyncFolder(data: data)
|
|
}
|
|
case .syncCipherDelete,
|
|
.syncLoginDelete:
|
|
if let data: SyncCipherNotification = notificationData.data(), data.userId == userId {
|
|
try await syncService.deleteCipher(data: data)
|
|
}
|
|
case .syncFolderDelete:
|
|
if let data: SyncFolderNotification = notificationData.data(), data.userId == userId {
|
|
try await syncService.deleteFolder(data: data)
|
|
}
|
|
case .syncCiphers,
|
|
.syncSettings,
|
|
.syncVault:
|
|
try await syncService.fetchSync(forceSync: false)
|
|
case .syncOrgKeys:
|
|
try await refreshableApiService.refreshAccessToken()
|
|
try await syncService.fetchSync(forceSync: true)
|
|
case .logOut:
|
|
guard let data: LogoutNotification = notificationData.data() else { return }
|
|
|
|
if data.reason == .kdfChange,
|
|
// TODO: PM-26960 Remove user ID check with noLogoutOnKdfChange feature flag.
|
|
data.userId == userId,
|
|
await configService.getFeatureFlag(.noLogoutOnKdfChange) {
|
|
// Don't log the user out for KDF changes.
|
|
break
|
|
}
|
|
|
|
try await authRepository.logout(userId: data.userId, userInitiated: true)
|
|
// Only route to landing page if the current active user was logged out.
|
|
if data.userId == userId {
|
|
await delegate?.routeToLanding()
|
|
}
|
|
case .syncSendCreate,
|
|
.syncSendUpdate:
|
|
if let data: SyncSendNotification = notificationData.data(), data.userId == userId {
|
|
try await syncService.fetchUpsertSyncSend(data: data)
|
|
}
|
|
case .syncSendDelete:
|
|
if let data: SyncSendNotification = notificationData.data(), data.userId == userId {
|
|
try await syncService.deleteSend(data: data)
|
|
}
|
|
case .authRequest:
|
|
try await handleLoginRequest(notificationData, userId: userId)
|
|
case .authRequestResponse:
|
|
// No action necessary, since the LoginWithDeviceProcessor already checks for updates
|
|
// every few seconds.
|
|
break
|
|
}
|
|
} catch {
|
|
errorReporter.log(error: error)
|
|
}
|
|
}
|
|
|
|
func notificationAuthorization() async -> UNAuthorizationStatus {
|
|
await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
|
|
}
|
|
|
|
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
|
try await UNUserNotificationCenter.current().requestAuthorization(options: options)
|
|
}
|
|
|
|
// MARK: Private Methods
|
|
|
|
/// A helper function to decode the push notification payload.
|
|
///
|
|
/// - Parameter message: The content of the push notification.
|
|
///
|
|
/// - Returns: The decoded push notification data.
|
|
///
|
|
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)
|
|
|
|
// Verify that the payload is not empty and that the context is correct.
|
|
let appId = await appIdService.getOrCreateAppId()
|
|
guard notificationData.payload?.isEmpty == false,
|
|
notificationData.contextId != appId
|
|
else { return nil }
|
|
return notificationData
|
|
}
|
|
|
|
/// A helper method to handle a login request push notification.
|
|
///
|
|
/// - Parameters:
|
|
/// - notificationData: The decoded payload from the push notification.
|
|
/// - userId: The user's id.
|
|
///
|
|
private func handleLoginRequest(_ notificationData: PushNotificationData, userId: String) async throws {
|
|
guard let data: LoginRequestNotification = notificationData.data() else { return }
|
|
|
|
// Save the notification data.
|
|
await stateService.setLoginRequest(data)
|
|
|
|
// Get the email of the account that the login request is coming from.
|
|
let loginSourceAccount = try await stateService.getAccount(userId: data.userId)
|
|
let loginSourceEmail = loginSourceAccount.profile.email
|
|
|
|
// 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.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)
|
|
|
|
if data.userId == userId {
|
|
// If the request is for the existing account, show the login request view automatically.
|
|
guard let loginRequest = try await authService.getPendingLoginRequest(withId: data.id).first
|
|
else { return }
|
|
await delegate?.showLoginRequest(loginRequest)
|
|
} else {
|
|
// Otherwise, show an alert asking the user if they want to switch accounts.
|
|
await delegate?.switchAccountsForLoginRequest(to: loginSourceAccount, showAlert: true)
|
|
}
|
|
}
|
|
|
|
/// Attempt to decode the notification data as a response to a login notification banner.
|
|
///
|
|
/// - Parameters:
|
|
/// - message: The content of the push notification.
|
|
/// - notificationDismissed: `true` if a notification banner has been dismissed.
|
|
/// - notificationTapped: `true` if a notification banner has been tapped.
|
|
///
|
|
/// - Returns: `true` if the message was able to be decoded as a response.
|
|
///
|
|
private func handleLoginRequestResponse(
|
|
_ message: [AnyHashable: Any],
|
|
notificationDismissed: Bool?,
|
|
notificationTapped: Bool?,
|
|
) async -> Bool {
|
|
if let content = message["notificationData"] as? String,
|
|
let jsonData = content.data(using: .utf8),
|
|
let loginRequestData = try? JSONDecoder.pascalOrSnakeCaseDecoder.decode(
|
|
LoginRequestPushNotification.self,
|
|
from: jsonData,
|
|
) {
|
|
if notificationDismissed == true {
|
|
await handleNotificationDismissed()
|
|
return true
|
|
}
|
|
if notificationTapped == true {
|
|
await handleNotificationTapped(loginRequestData)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Handle a banner notification being dismissed.
|
|
private func handleNotificationDismissed() async {
|
|
// If the notification banner was dismissed, clear the cached value.
|
|
await stateService.setLoginRequest(nil)
|
|
}
|
|
|
|
/// Handle a banner notification with login request data being tapped.
|
|
private func handleNotificationTapped(_ loginRequestData: LoginRequestPushNotification) async {
|
|
do {
|
|
// Get the user id of the source of the login request.
|
|
let loginSourceAccount = try await stateService.getAccount(userId: loginRequestData.userId)
|
|
|
|
// Get the active account for comparison.
|
|
let activeAccount = try await stateService.getActiveAccount()
|
|
|
|
// If the notification banner was tapped but it's for a different account, switch
|
|
// to that account automatically.
|
|
if activeAccount.profile.userId != loginSourceAccount.profile.userId {
|
|
await delegate?.switchAccountsForLoginRequest(to: loginSourceAccount, showAlert: false)
|
|
}
|
|
} catch {
|
|
errorReporter.log(error: error)
|
|
}
|
|
}
|
|
} // swiftlint:disable:this file_length
|