mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 00:42:29 -06:00
775 lines
30 KiB
Swift
775 lines
30 KiB
Swift
import AuthenticationServices
|
|
import BitwardenKit
|
|
import BitwardenResources
|
|
import BitwardenSdk
|
|
import Combine
|
|
import Foundation
|
|
import UIKit
|
|
|
|
// MARK: - AppLinksError
|
|
|
|
/// The errors thrown from a `AppProcessor`.
|
|
///
|
|
enum AppProcessorError: Error {
|
|
/// The received URL from AppLinks is malformed.
|
|
case appLinksInvalidURL
|
|
|
|
/// The received URL from AppLinks does not have the correct parameters.
|
|
case appLinksInvalidParametersForPath
|
|
|
|
/// The received URL from AppLinks does not have a valid path.
|
|
case appLinksInvalidPath
|
|
|
|
/// The operation to execute is invalid.
|
|
case invalidOperation
|
|
}
|
|
|
|
/// The `AppProcessor` processes actions received at the application level and contains the logic
|
|
/// to control the top-level flow through the app.
|
|
///
|
|
@MainActor
|
|
public class AppProcessor {
|
|
// MARK: Properties
|
|
|
|
/// A delegate used to communicate with the app extension.
|
|
private(set) weak var appExtensionDelegate: AppExtensionDelegate?
|
|
|
|
/// The mediator to handle `AppIntent` actions.
|
|
private let appIntentMediator: AppIntentMediator?
|
|
|
|
/// The root module to use to create sub-coordinators.
|
|
let appModule: AppModule
|
|
|
|
/// The background task ID for the background process to send events on backgrounding.
|
|
var backgroundTaskId: UIBackgroundTaskIdentifier?
|
|
|
|
/// The root coordinator of the app.
|
|
var coordinator: AnyCoordinator<AppRoute, AppEvent>?
|
|
|
|
/// A timer to send any accumulated events every five minutes.
|
|
private(set) var sendEventTimer: Timer?
|
|
|
|
/// The services used by the app.
|
|
let services: ServiceContainer
|
|
|
|
// MARK: Initialization
|
|
|
|
/// Initializes an `AppProcessor`.
|
|
///
|
|
/// - Parameters:
|
|
/// - appExtensionDelegate: A delegate used to communicate with the app extension.
|
|
/// - appIntentMediator: The mediator to handle `AppIntent` actions.
|
|
/// - appModule: The root module to use to create sub-coordinators.
|
|
/// - debugDidEnterBackground: A closure that is called in debug builds for testing after the
|
|
/// processor finishes its work when the app enters the background.
|
|
/// - debugWillEnterForeground: A closure that is called in debug builds for testing after the
|
|
/// processor finishes its work when the app enters the foreground.
|
|
/// - services: The services used by the app.
|
|
///
|
|
public init(
|
|
appExtensionDelegate: AppExtensionDelegate? = nil,
|
|
appIntentMediator: AppIntentMediator? = nil,
|
|
appModule: AppModule,
|
|
debugDidEnterBackground: (() -> Void)? = nil,
|
|
debugWillEnterForeground: (() -> Void)? = nil,
|
|
services: ServiceContainer,
|
|
) {
|
|
self.appExtensionDelegate = appExtensionDelegate
|
|
self.appIntentMediator = appIntentMediator
|
|
self.appModule = appModule
|
|
self.services = services
|
|
|
|
self.services.notificationService.setDelegate(self)
|
|
self.services.pendingAppIntentActionMediator.setDelegate(self)
|
|
self.services.syncService.delegate = self
|
|
|
|
Task {
|
|
await services.apiService.setAccountTokenProviderDelegate(delegate: self)
|
|
}
|
|
|
|
startEventTimer()
|
|
|
|
UI.initialLanguageCode = services.appSettingsStore.appLocale ?? Bundle.main.preferredLocalizations.first
|
|
UI.applyDefaultAppearances()
|
|
|
|
Task {
|
|
for await _ in services.notificationCenterService.willEnterForegroundPublisher() {
|
|
startEventTimer()
|
|
await checkIfExtensionSwitchedAccounts()
|
|
await services.authRepository.checkSessionTimeouts { [weak self] activeUserId in
|
|
// Allow the AuthCoordinator to handle the timeout for the active user
|
|
// so any necessary routing can occur.
|
|
await self?.coordinator?.handleEvent(.didTimeout(userId: activeUserId))
|
|
}
|
|
await handleNeverTimeOutAccountBecameActive()
|
|
await completeAutofillAccountSetupIfEnabled()
|
|
#if DEBUG
|
|
debugWillEnterForeground?()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
Task {
|
|
for await _ in services.notificationCenterService.didEnterBackgroundPublisher() {
|
|
stopEventTimer()
|
|
do {
|
|
let userId = try await self.services.stateService.getActiveAccountId()
|
|
try await services.vaultTimeoutService.setLastActiveTime(userId: userId)
|
|
} catch StateServiceError.noActiveAccount {
|
|
// No-op: nothing to do if there's no active account.
|
|
} catch {
|
|
services.errorReporter.log(error: error)
|
|
}
|
|
#if DEBUG
|
|
debugDidEnterBackground?()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// PM-19400: We need to listen to the changes on pending app intent actions
|
|
// so they get executed and update the navigation/UI accordingly.
|
|
Task {
|
|
for await _ in await services.stateService.pendingAppIntentActionsPublisher().values {
|
|
await services.pendingAppIntentActionMediator.executePendingAppIntentActions()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
/// Handles receiving a deep link URL and routing to the appropriate place in the app.
|
|
///
|
|
/// - Parameter url: The deep link URL to handle.
|
|
///
|
|
public func openUrl(_ url: URL) async {
|
|
var route = await getBitwardenUrlRoute(url: url)
|
|
if route == nil {
|
|
route = await getOtpAuthUrlRoute(url: url)
|
|
}
|
|
guard let route else { return }
|
|
await checkIfLockedAndPerformNavigation(route: route)
|
|
}
|
|
|
|
/// Starts the application flow by navigating the user to the first flow.
|
|
///
|
|
/// - Parameters:
|
|
/// - appContext: The context that the app is running within.
|
|
/// - initialRoute: The initial route to navigate to. If `nil` this, will navigate to the
|
|
/// unlock or landing auth route based on if there's an active account. Defaults to `nil`.
|
|
/// - navigator: The object that will be used to navigate between routes.
|
|
/// - splashWindow: The splash window to use to set the app's theme.
|
|
/// - window: The window to use to set the app's theme.
|
|
///
|
|
public func start(
|
|
appContext: AppContext,
|
|
initialRoute: AppRoute? = nil,
|
|
navigator: RootNavigator,
|
|
splashWindow: UIWindow? = nil,
|
|
window: UIWindow?,
|
|
) async {
|
|
let coordinator = appModule.makeAppCoordinator(appContext: appContext, navigator: navigator)
|
|
coordinator.start()
|
|
self.coordinator = coordinator
|
|
|
|
Task {
|
|
for await appTheme in await services.stateService.appThemePublisher().values {
|
|
navigator.appTheme = appTheme
|
|
splashWindow?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
|
|
window?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
|
|
}
|
|
}
|
|
|
|
await services.flightRecorder.log(
|
|
"App launched, context: \(appContext), version: \(Bundle.main.appVersion) (\(Bundle.main.buildNumber))",
|
|
)
|
|
|
|
await services.migrationService.performMigrations()
|
|
await prepareEnvironmentConfig()
|
|
await completeAutofillAccountSetupIfEnabled()
|
|
|
|
if let initialRoute {
|
|
coordinator.navigate(to: initialRoute)
|
|
} else {
|
|
await coordinator.handleEvent(.didStart)
|
|
}
|
|
}
|
|
|
|
/// Handle incoming URL from iOS AppLinks and redirect it to the correct navigation within the App
|
|
///
|
|
/// - Parameter incomingURL: The URL handled from AppLinks.
|
|
///
|
|
public func handleAppLinks(incomingURL: URL) {
|
|
guard let sanitizedUrl = URL(
|
|
string: incomingURL.absoluteString.replacingOccurrences(of: "/redirect-connector.html#", with: "/"),
|
|
),
|
|
let components = URLComponents(url: sanitizedUrl, resolvingAgainstBaseURL: true) else {
|
|
return
|
|
}
|
|
|
|
// Check for specific URL components that you need.
|
|
guard let params = components.queryItems,
|
|
components.host != nil else {
|
|
services.errorReporter.log(error: AppProcessorError.appLinksInvalidURL)
|
|
return
|
|
}
|
|
|
|
guard components.path == "/finish-signup" else {
|
|
services.errorReporter.log(error: AppProcessorError.appLinksInvalidPath)
|
|
return
|
|
}
|
|
guard let email = params.first(where: { $0.name == "email" })?.value,
|
|
let verificationToken = params.first(where: { $0.name == "token" })?.value,
|
|
let fromEmail = params.first(where: { $0.name == "fromEmail" })?.value
|
|
else {
|
|
services.errorReporter.log(error: AppProcessorError.appLinksInvalidParametersForPath)
|
|
return
|
|
}
|
|
|
|
coordinator?.navigate(to: AppRoute.auth(
|
|
AuthRoute.completeRegistrationFromAppLink(
|
|
emailVerificationToken: verificationToken,
|
|
userEmail: email,
|
|
fromEmail: Bool(fromEmail) ?? true,
|
|
)))
|
|
}
|
|
|
|
/// Handles importing credentials using Credential Exchange Protocol.
|
|
/// - Parameter credentialImportToken: The credentials import token to user with the `ASCredentialImportManager`.
|
|
@available(iOSApplicationExtension 26.0, *)
|
|
public func handleImportCredentials(credentialImportToken: UUID) async {
|
|
let route = AppRoute.tab(.vault(.importCXF(
|
|
.importCredentials(credentialImportToken: credentialImportToken),
|
|
)))
|
|
await checkIfLockedAndPerformNavigation(route: route)
|
|
}
|
|
|
|
/// Perpares the current environment configuration by loading the URLs for the active account
|
|
/// and getting the current server config.
|
|
public func prepareEnvironmentConfig() async {
|
|
await services.environmentService.loadURLsForActiveAccount()
|
|
_ = await services.configService.getConfig()
|
|
}
|
|
|
|
// MARK: Autofill Methods
|
|
|
|
/// Returns a `ASPasswordCredential` that matches the user-requested credential which can be
|
|
/// used for autofill.
|
|
///
|
|
/// - Parameters:
|
|
/// - id: The identifier of the user-requested credential to return.
|
|
/// - repromptPasswordValidated: `true` if master password reprompt was required for the
|
|
/// cipher and the user's master password was validated.
|
|
/// - Returns: A `ASPasswordCredential` that matches the user-requested credential which can be
|
|
/// used for autofill.
|
|
///
|
|
public func provideCredential(
|
|
for id: String,
|
|
repromptPasswordValidated: Bool = false,
|
|
) async throws -> ASPasswordCredential {
|
|
try await services.autofillCredentialService.provideCredential(
|
|
for: id,
|
|
autofillCredentialServiceDelegate: self,
|
|
repromptPasswordValidated: repromptPasswordValidated,
|
|
)
|
|
}
|
|
|
|
/// Provides an OTP credential for the identity
|
|
/// - Parameters:
|
|
/// - id: The identifier of the user-requested credential to return
|
|
/// - repromptPasswordValidated: true` if master password reprompt was required for the
|
|
/// cipher and the user's master password was validated.
|
|
/// - Returns: An `ASOneTimeCodeCredential` that matches the user-requested credential which can be
|
|
/// used for autofill..
|
|
@available(iOSApplicationExtension 18.0, *)
|
|
public func provideOTPCredential(
|
|
for id: String,
|
|
repromptPasswordValidated: Bool = false,
|
|
) async throws -> ASOneTimeCodeCredential {
|
|
try await services.autofillCredentialService.provideOTPCredential(
|
|
for: id,
|
|
autofillCredentialServiceDelegate: self,
|
|
repromptPasswordValidated: repromptPasswordValidated,
|
|
)
|
|
}
|
|
|
|
/// Reprompts the user for their master password if the cipher for the user-requested credential
|
|
/// requires reprompt. Once reprompt has been completed (or when it's not required), the
|
|
/// `completion` closure is called notifying the caller if the master password was validated
|
|
/// successfully for reprompt.
|
|
///
|
|
/// - Parameters:
|
|
/// - id: The identifier of the user-requested credential to return.
|
|
/// - completion: A closure that is called containing a bool that identifies if the user's
|
|
/// master password was validated successfully. This will be `false` if reprompt wasn't
|
|
/// required or if it is required and the master password was incorrect.
|
|
///
|
|
public func repromptForCredentialIfNecessary(
|
|
for id: String,
|
|
completion: @escaping (Bool) async -> Void,
|
|
) async throws {
|
|
guard try await services.vaultRepository.repromptRequiredForCipher(id: id) else {
|
|
await completion(false)
|
|
return
|
|
}
|
|
|
|
let alert = Alert.masterPasswordPrompt { password in
|
|
do {
|
|
let isValid = try await self.services.authRepository.validatePassword(password)
|
|
guard isValid else {
|
|
self.coordinator?.showAlert(.defaultAlert(title: Localizations.invalidMasterPassword)) {
|
|
Task {
|
|
await completion(false)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
await completion(true)
|
|
} catch {
|
|
self.services.errorReporter.log(error: error)
|
|
await completion(false)
|
|
}
|
|
}
|
|
coordinator?.showAlert(alert)
|
|
}
|
|
|
|
/// Show the debug menu.
|
|
public func showDebugMenu() {
|
|
coordinator?.navigate(to: .debugMenu)
|
|
}
|
|
|
|
// MARK: Notification Methods
|
|
|
|
/// Called when the app has registered for push notifications.
|
|
///
|
|
/// - Parameter tokenData: The device token for push notifications.
|
|
///
|
|
public func didRegister(withToken tokenData: Data) {
|
|
Task {
|
|
await services.notificationService.didRegister(withToken: tokenData)
|
|
}
|
|
}
|
|
|
|
/// Called when the app failed to register for push notifications.
|
|
///
|
|
/// - Parameter error: The error received.
|
|
///
|
|
public func failedToRegister(_ error: Error) {
|
|
services.errorReporter.log(error: error)
|
|
}
|
|
|
|
/// Called when the app has received data from a push notification.
|
|
///
|
|
/// - 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.
|
|
///
|
|
public func messageReceived(
|
|
_ message: [AnyHashable: Any],
|
|
notificationDismissed: Bool? = nil,
|
|
notificationTapped: Bool? = nil,
|
|
) async {
|
|
await services.notificationService.messageReceived(
|
|
message,
|
|
notificationDismissed: notificationDismissed,
|
|
notificationTapped: notificationTapped,
|
|
)
|
|
}
|
|
}
|
|
|
|
extension AppProcessor {
|
|
// MARK: Private Methods
|
|
|
|
/// Whether there are pending `AppIntent` lock actions.
|
|
private func hasLockPendingAppIntentAction() async -> Bool {
|
|
guard let actions = await services.stateService.getPendingAppIntentActions(),
|
|
!actions.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
return actions.contains(where: { $0 == .lockAll })
|
|
}
|
|
|
|
/// Handles unlocking the vault for a manually locked account that uses never lock
|
|
/// and was previously unlocked in an extension.
|
|
///
|
|
private func handleNeverTimeOutAccountBecameActive() async {
|
|
guard
|
|
appExtensionDelegate?.isInAppExtension != true,
|
|
await (try? services.authRepository.isLocked()) == true,
|
|
await (try? services.authRepository.sessionTimeoutValue()) == .never,
|
|
await (try? services.stateService.getManuallyLockedAccount(userId: nil)) == false,
|
|
await !hasLockPendingAppIntentAction(),
|
|
let account = try? await services.stateService.getActiveAccount()
|
|
else { return }
|
|
|
|
await coordinator?.handleEvent(
|
|
.accountBecameActive(
|
|
account,
|
|
attemptAutomaticBiometricUnlock: true,
|
|
didSwitchAccountAutomatically: false,
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Checks if the active account was switched while in the extension. If this occurs, the app
|
|
/// needs to also switch to the updated active account.
|
|
///
|
|
private func checkIfExtensionSwitchedAccounts() async {
|
|
guard appExtensionDelegate?.isInAppExtension != true else { return }
|
|
do {
|
|
guard try await services.stateService.didAccountSwitchInExtension() == true else { return }
|
|
let userId = try await services.stateService.getActiveAccountId()
|
|
await coordinator?.handleEvent(.switchAccounts(userId: userId, isAutomatic: false))
|
|
} catch StateServiceError.noActiveAccount {
|
|
await coordinator?.handleEvent(.didStart)
|
|
} catch {
|
|
services.errorReporter.log(error: error)
|
|
}
|
|
}
|
|
|
|
/// Checks if the vault is locked and performs the navigation to the `AppRoute`
|
|
/// or sets it as the auth completion route.
|
|
/// - Parameter route: The `AppRoute` to go to.
|
|
private func checkIfLockedAndPerformNavigation(route: AppRoute) async {
|
|
if let userId = try? await services.stateService.getActiveAccountId(),
|
|
!services.vaultTimeoutService.isLocked(userId: userId),
|
|
await (try? services.vaultTimeoutService.hasPassedSessionTimeout(userId: userId)) == false {
|
|
coordinator?.navigate(to: route)
|
|
} else {
|
|
await coordinator?.handleEvent(.setAuthCompletionRoute(route))
|
|
}
|
|
}
|
|
|
|
/// If the native create account feature flag and the autofill extension are enabled, this marks
|
|
/// any user's autofill account setup completed. This should be called on app startup.
|
|
///
|
|
private func completeAutofillAccountSetupIfEnabled() async {
|
|
// Don't mark the user's progress as complete in the extension, otherwise the app may not
|
|
// see that the user's progress needs to be updated to publish new values to subscribers.
|
|
guard appExtensionDelegate?.isInAppExtension != true,
|
|
await services.autofillCredentialService.isAutofillCredentialsEnabled()
|
|
else { return }
|
|
do {
|
|
let accounts = try await services.stateService.getAccounts()
|
|
for account in accounts {
|
|
let userId = account.profile.userId
|
|
guard let progress = await services.stateService.getAccountSetupAutofill(userId: userId),
|
|
progress != .complete
|
|
else { continue }
|
|
try await services.stateService.setAccountSetupAutofill(.complete, userId: userId)
|
|
}
|
|
} catch StateServiceError.noAccounts {
|
|
// No-op: nothing to do if there's no accounts.
|
|
} catch {
|
|
services.errorReporter.log(error: error)
|
|
}
|
|
}
|
|
|
|
/// Attempt to create an `AppRoute` from an "bitwarden://" url.
|
|
///
|
|
/// - Parameter url: The Bitwarden URL received by the app.
|
|
/// - Returns: an `AppRoute` if one was successfully built from the URL passed in, `nil` if not.
|
|
///
|
|
private func getBitwardenUrlRoute(url: URL) async -> AppRoute? {
|
|
guard let scheme = url.scheme, scheme.isBitwardenAppScheme else { return nil }
|
|
|
|
switch url.absoluteString {
|
|
case BitwardenDeepLinkConstants.accountSecurity:
|
|
return AppRoute.tab(.settings(.accountSecurity))
|
|
case BitwardenDeepLinkConstants.authenticatorNewItem:
|
|
guard let item = await services.authenticatorSyncService?.getTemporaryTotpItem(),
|
|
let totpKey = item.totpKey else {
|
|
coordinator?.showAlert(.defaultAlert(title: Localizations.somethingWentWrong,
|
|
message: Localizations.unableToMoveTheSelectedItemPleaseTryAgain))
|
|
return nil
|
|
}
|
|
|
|
let totpKeyModel = TOTPKeyModel(authenticatorKey: totpKey)
|
|
return AppRoute.tab(.vault(.vaultItemSelection(totpKeyModel)))
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Attempt to create an `AppRoute` from an "otpauth://" url.
|
|
///
|
|
/// - Parameter url: The OTPAuth URL received by the app.
|
|
/// - Returns: an `AppRoute` if one was successfully built from the URL passed in, `nil` if not.
|
|
///
|
|
private func getOtpAuthUrlRoute(url: URL) async -> AppRoute? {
|
|
guard let scheme = url.scheme, scheme.isOtpAuthScheme else { return nil }
|
|
|
|
let totpKeyModel = TOTPKeyModel(authenticatorKey: url.absoluteString)
|
|
guard case .otpAuthUri = totpKeyModel.totpKey else {
|
|
coordinator?.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
|
|
return nil
|
|
}
|
|
|
|
return AppRoute.tab(.vault(.vaultItemSelection(totpKeyModel)))
|
|
}
|
|
|
|
/// Logs out the user automatically, if `nil` is passed as `userId` then it will act on the current user.
|
|
/// - Parameter userId: The ID of the user to logout, current if `nil`.
|
|
private func logOutAutomatically(userId: String? = nil) async {
|
|
coordinator?.hideLoadingOverlay()
|
|
do {
|
|
try await services.authRepository.logout(userId: userId, userInitiated: false)
|
|
} catch {
|
|
services.errorReporter.log(error: error)
|
|
}
|
|
await coordinator?.handleEvent(.didLogout(userId: userId, userInitiated: false))
|
|
}
|
|
|
|
/// Starts timer to send organization events regularly
|
|
private func startEventTimer() {
|
|
sendEventTimer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) { _ in
|
|
Task { [weak self] in
|
|
await self?.uploadEvents()
|
|
}
|
|
}
|
|
sendEventTimer?.tolerance = 10
|
|
}
|
|
|
|
/// Stops the timer for organization events
|
|
private func stopEventTimer() {
|
|
sendEventTimer?.fire()
|
|
sendEventTimer?.invalidate()
|
|
}
|
|
|
|
/// Sends organization events to the server. Also sets up that regular upload
|
|
/// as a Background Task so that it won't be canceled when the app is going
|
|
/// to the background. Per https://forums.developer.apple.com/forums/thread/85066
|
|
/// calling this for every upload (not just ones where we're backgrounding)
|
|
/// is fine.
|
|
private func uploadEvents() async {
|
|
if let taskId = backgroundTaskId {
|
|
services.application?.endBackgroundTask(taskId)
|
|
backgroundTaskId = nil
|
|
}
|
|
backgroundTaskId = services.application?.startBackgroundTask(
|
|
withName: "SendEventBackgroundTask",
|
|
expirationHandler: { [weak self] in
|
|
if let backgroundTaskId = self?.backgroundTaskId {
|
|
self?.services.application?.endBackgroundTask(backgroundTaskId)
|
|
self?.backgroundTaskId = nil
|
|
}
|
|
},
|
|
)
|
|
await services.eventService.upload()
|
|
if let taskId = backgroundTaskId {
|
|
services.application?.endBackgroundTask(taskId)
|
|
backgroundTaskId = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - NotificationServiceDelegate
|
|
|
|
extension AppProcessor: NotificationServiceDelegate {
|
|
/// Users are logged out, route to landing page.
|
|
///
|
|
func routeToLanding() async {
|
|
coordinator?.navigate(to: .auth(.landing))
|
|
}
|
|
|
|
/// Show the login request.
|
|
///
|
|
/// - Parameter loginRequest: The login request.
|
|
///
|
|
func showLoginRequest(_ loginRequest: LoginRequest) {
|
|
coordinator?.navigate(to: .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 {
|
|
if showAlert {
|
|
coordinator?.showAlert(.confirmation(
|
|
title: Localizations.logInRequested,
|
|
message: Localizations.loginAttemptFromXDoYouWantToSwitchToThisAccount(account.profile.email),
|
|
) {
|
|
await self.switchAccountsForLoginRequest(to: account.profile.userId)
|
|
})
|
|
} else {
|
|
await switchAccountsForLoginRequest(to: account.profile.userId)
|
|
}
|
|
}
|
|
|
|
/// Switch to the specified account so they can see the login request.
|
|
///
|
|
/// - Parameter userId: The user ID of the account to switch to.
|
|
///
|
|
private func switchAccountsForLoginRequest(to userId: String) async {
|
|
// Switch to the account, the login request will be shown when their vault loads (either
|
|
// immediately or after vault unlock).
|
|
await coordinator?.handleEvent(.switchAccounts(userId: userId, isAutomatic: false))
|
|
}
|
|
}
|
|
|
|
// MARK: - SyncServiceDelegate
|
|
|
|
extension AppProcessor: SyncServiceDelegate {
|
|
func onFetchSyncSucceeded(userId: String) async {
|
|
do {
|
|
let hasPerformedSyncAfterLogin = try await services.stateService.getHasPerformedSyncAfterLogin(
|
|
userId: userId,
|
|
)
|
|
// Check so the next gets executed only once after login.
|
|
guard !hasPerformedSyncAfterLogin else {
|
|
return
|
|
}
|
|
try await services.stateService.setHasPerformedSyncAfterLogin(true, userId: userId)
|
|
|
|
if await services.policyService.policyAppliesToUser(.removeUnlockWithPin) {
|
|
try await services.stateService.clearPins()
|
|
}
|
|
} catch {
|
|
services.errorReporter.log(error: error)
|
|
}
|
|
}
|
|
|
|
func removeMasterPassword(organizationName: String, organizationId: String, keyConnectorUrl: String) {
|
|
// Don't show the remove master password screen if running in an app extension.
|
|
guard appExtensionDelegate?.isInAppExtension != true else { return }
|
|
|
|
coordinator?.hideLoadingOverlay()
|
|
coordinator?.navigate(to: .auth(.removeMasterPassword(
|
|
organizationName: organizationName,
|
|
organizationId: organizationId,
|
|
keyConnectorUrl: keyConnectorUrl,
|
|
)))
|
|
}
|
|
|
|
func securityStampChanged(userId: String) async {
|
|
// Log the user out if their security stamp changes.
|
|
await logOutAutomatically(userId: userId)
|
|
}
|
|
|
|
func setMasterPassword(orgIdentifier: String) async {
|
|
DispatchQueue.main.async { [self] in
|
|
coordinator?.navigate(to: .auth(.setMasterPassword(organizationIdentifier: orgIdentifier)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Fido2 credentials
|
|
|
|
public extension AppProcessor {
|
|
/// Provides a Fido2 credential for a passkey request
|
|
/// - Parameters:
|
|
/// - passkeyRequest: Request to get the credential.
|
|
/// - Returns: The passkey credential for assertion.
|
|
@available(iOS 17.0, *)
|
|
func provideFido2Credential(
|
|
for passkeyRequest: ASPasskeyCredentialRequest,
|
|
) async throws -> ASPasskeyAssertionCredential {
|
|
try await services.autofillCredentialService.provideFido2Credential(
|
|
for: passkeyRequest,
|
|
autofillCredentialServiceDelegate: self,
|
|
fido2UserInterfaceHelperDelegate: self,
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - AccountTokenProviderDelegate
|
|
|
|
extension AppProcessor: AccountTokenProviderDelegate {
|
|
func onRefreshTokenError(error: any Error) async throws {
|
|
if case IdentityTokenRefreshRequestError.invalidGrant = error {
|
|
await logOutAutomatically()
|
|
} else if let error = error as? ResponseValidationError, [401, 403].contains(error.response.statusCode) {
|
|
await logOutAutomatically()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AutofillCredentialServiceDelegate
|
|
|
|
extension AppProcessor: AutofillCredentialServiceDelegate {
|
|
func unlockVaultWithNeverlockKey() async throws {
|
|
try await services.authRepository.unlockVaultWithNeverlockKey()
|
|
}
|
|
}
|
|
|
|
// MARK: - Fido2UserVerificationMediatorDelegate
|
|
|
|
extension AppProcessor: Fido2UserInterfaceHelperDelegate {
|
|
// MARK: Properties
|
|
|
|
var isAutofillingFromList: Bool {
|
|
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate,
|
|
autofillAppExtensionDelegate.isAutofillingFido2CredentialFromList else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
func informExcludedCredentialFound(cipherView: BitwardenSdk.CipherView) async {
|
|
// No-op
|
|
}
|
|
|
|
func onNeedsUserInteraction() async throws {
|
|
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate else {
|
|
return
|
|
}
|
|
|
|
if !autofillAppExtensionDelegate.flowWithUserInteraction {
|
|
autofillAppExtensionDelegate.setUserInteractionRequired()
|
|
throw Fido2Error.userInteractionRequired
|
|
}
|
|
|
|
// WORKAROUND: We need to wait until the view controller appears in order to perform any
|
|
// action that needs user interaction or it might not show the prompt to the user.
|
|
// E.g. without this there are certain devices that don't show the FaceID prompt
|
|
// and the user only sees the screen dimming a bit and failing the flow.
|
|
for await didAppear in autofillAppExtensionDelegate.getDidAppearPublisher() {
|
|
guard didAppear else { continue }
|
|
return
|
|
}
|
|
}
|
|
|
|
func showAlert(_ alert: Alert) {
|
|
coordinator?.showAlert(alert)
|
|
}
|
|
|
|
func showAlert(_ alert: Alert, onDismissed: (() -> Void)?) {
|
|
coordinator?.showAlert(alert, onDismissed: onDismissed)
|
|
}
|
|
}
|
|
|
|
// MARK: - PendingAppIntentActionMediatorDelegate
|
|
|
|
extension AppProcessor: PendingAppIntentActionMediatorDelegate {
|
|
func onPendingAppIntentActionSuccess(
|
|
_ pendingAppIntentAction: PendingAppIntentAction,
|
|
data: Any?,
|
|
) async {
|
|
switch pendingAppIntentAction {
|
|
case .lockAll:
|
|
guard let account = data as? Account else {
|
|
return
|
|
}
|
|
await coordinator?.handleEvent(
|
|
.accountBecameActive(
|
|
account,
|
|
attemptAutomaticBiometricUnlock: true,
|
|
didSwitchAccountAutomatically: false,
|
|
),
|
|
)
|
|
case .logOutAll:
|
|
await coordinator?.handleEvent(.didLogout(userId: nil, userInitiated: true))
|
|
case .openGenerator:
|
|
await checkIfLockedAndPerformNavigation(route: .tab(.generator(.generator())))
|
|
}
|
|
}
|
|
}
|
|
|
|
// swiftlint:disable:this file_length
|