diff --git a/wire-ios/Tests/Sourcery/generated/AutoMockable.generated.swift b/wire-ios/Tests/Sourcery/generated/AutoMockable.generated.swift index 3ed281b0eb6..e09f3f1937f 100644 --- a/wire-ios/Tests/Sourcery/generated/AutoMockable.generated.swift +++ b/wire-ios/Tests/Sourcery/generated/AutoMockable.generated.swift @@ -969,6 +969,37 @@ class MockNetworkStatusViewDelegate: NetworkStatusViewDelegate { } +class MockOAuthUseCaseInterface: OAuthUseCaseInterface { + + // MARK: - Life cycle + + + + // MARK: - invoke + + var invokeParametersOnWebViewPresentingOnWebViewDismissed_Invocations: [(parameters: OAuthParameters, onWebViewPresenting: (@MainActor () -> Void)?, onWebViewDismissed: (@MainActor () -> Void)?)] = [] + var invokeParametersOnWebViewPresentingOnWebViewDismissed_MockError: Error? + var invokeParametersOnWebViewPresentingOnWebViewDismissed_MockMethod: ((OAuthParameters, (@MainActor () -> Void)?, (@MainActor () -> Void)?) async throws -> OAuthResponse)? + var invokeParametersOnWebViewPresentingOnWebViewDismissed_MockValue: OAuthResponse? + + func invoke(parameters: OAuthParameters, onWebViewPresenting: (@MainActor () -> Void)?, onWebViewDismissed: (@MainActor () -> Void)?) async throws -> OAuthResponse { + invokeParametersOnWebViewPresentingOnWebViewDismissed_Invocations.append((parameters: parameters, onWebViewPresenting: onWebViewPresenting, onWebViewDismissed: onWebViewDismissed)) + + if let error = invokeParametersOnWebViewPresentingOnWebViewDismissed_MockError { + throw error + } + + if let mock = invokeParametersOnWebViewPresentingOnWebViewDismissed_MockMethod { + return try await mock(parameters, onWebViewPresenting, onWebViewDismissed) + } else if let mock = invokeParametersOnWebViewPresentingOnWebViewDismissed_MockValue { + return mock + } else { + fatalError("no mock for `invokeParametersOnWebViewPresentingOnWebViewDismissed`") + } + } + +} + class MockProfileActionsFactoryProtocol: ProfileActionsFactoryProtocol { // MARK: - Life cycle diff --git a/wire-ios/Wire-iOS Tests/UserInterface/E2EIdentity/E2EIEnrollmentFlowTests.swift b/wire-ios/Wire-iOS Tests/UserInterface/E2EIdentity/E2EIEnrollmentFlowTests.swift new file mode 100644 index 00000000000..7a12d03f843 --- /dev/null +++ b/wire-ios/Wire-iOS Tests/UserInterface/E2EIdentity/E2EIEnrollmentFlowTests.swift @@ -0,0 +1,113 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import XCTest + +@testable import Wire +@testable import WireRequestStrategy + +@MainActor +final class E2EIEnrollmentFlowTests: XCTestCase { + + private var oauthUseCase: MockOAuthUseCaseInterface! + private var targetVC: UIViewController! + private var window: UIWindow! + + override func setUp() { + oauthUseCase = MockOAuthUseCaseInterface() + targetVC = UIViewController() + window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = targetVC + window.isHidden = false + } + + override func tearDown() { + oauthUseCase = nil + targetVC = nil + window?.isHidden = true + window = nil + } + + // MARK: - OAuth integration + + func test_authenticate_callsOAuthUseCaseInvoke() async throws { + // Given + oauthUseCase.invokeParametersOnWebViewPresentingOnWebViewDismissed_MockValue = OAuthResponse( + idToken: "id", + refreshToken: "refresh" + ) + let sut = makeSUT() + + // When + _ = try await sut.authenticate(makeParameters()) + + // Then + XCTAssertEqual(oauthUseCase.invokeParametersOnWebViewPresentingOnWebViewDismissed_Invocations.count, 1) + } + + func test_authenticate_returnsResponseFromUseCase() async throws { + // Given + let expectedResponse = OAuthResponse(idToken: "expected-id-token", refreshToken: "refresh") + oauthUseCase.invokeParametersOnWebViewPresentingOnWebViewDismissed_MockValue = expectedResponse + let sut = makeSUT() + + // When + let result = try await sut.authenticate(makeParameters()) + + // Then + XCTAssertEqual(result.idToken, expectedResponse.idToken) + } + + func test_authenticate_propagatesErrorFromUseCase() async { + // Given + oauthUseCase.invokeParametersOnWebViewPresentingOnWebViewDismissed_MockError = TestError.boom + let sut = makeSUT() + + // When / Then + do { + _ = try await sut.authenticate(makeParameters()) + XCTFail("Expected error to be thrown") + } catch TestError.boom { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - Helpers + + private func makeSUT() -> E2EIEnrollmentFlow { + E2EIEnrollmentFlow( + oauthUseCase: oauthUseCase, + targetVC: { [unowned self] in targetVC } + ) + } + + private func makeParameters() -> OAuthParameters { + OAuthParameters( + identityProvider: URL(string: "https://idp.example.com")!, + clientID: "test-client", + keyauth: "test-keyauth", + acmeAudience: "test-audience" + ) + } + + private enum TestError: Error { + case boom + } +} diff --git a/wire-ios/Wire-iOS/Sources/Authentication/Coordinator/AuthenticationCoordinator.swift b/wire-ios/Wire-iOS/Sources/Authentication/Coordinator/AuthenticationCoordinator.swift index 59862b70017..147827ecaf6 100644 --- a/wire-ios/Wire-iOS/Sources/Authentication/Coordinator/AuthenticationCoordinator.swift +++ b/wire-ios/Wire-iOS/Sources/Authentication/Coordinator/AuthenticationCoordinator.swift @@ -864,7 +864,8 @@ extension AuthenticationCoordinator { Task { @MainActor in do { - let certificateChain = try await e2eiCertificateUseCase.invoke(authenticate: oauthUseCase.invoke) + let certificateChain = try await e2eiCertificateUseCase + .invoke(authenticate: { try await oauthUseCase.invoke(parameters: $0) }) executeActions([ .hideLoadingView, .transition(.enrollE2EIdentitySuccess(certificateChain), mode: .reset) diff --git a/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiView.swift b/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiView.swift index ba77e3045b0..3d5ed9461f8 100644 --- a/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiView.swift +++ b/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiView.swift @@ -61,7 +61,21 @@ struct DeveloperE2eiView: View { } } - Button(String("Enroll"), action: { viewModel.enrollCertificate() }) + VStack(alignment: .leading) { + Button(String("Enroll"), action: { viewModel.enrollCertificate() }) + footNote( + "Starts the enrollment flow with the selected expiration time." + ) + } + + VStack(alignment: .leading) { + Button(String("Show update certificate alert")) { + viewModel.showUpdateCertificateAlert(canRemindLater: false) + } + footNote( + "Manually triggers the \"Update Certificate\" popup. Tapping \"Update Certificate\" in the alert starts the enrollment flow with the selected expiration time." + ) + } } Section("Certificate Revocation Lists") { diff --git a/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiViewModel.swift b/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiViewModel.swift index 0c743ce42b3..ea86a193bf1 100644 --- a/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiViewModel.swift +++ b/wire-ios/Wire-iOS/Sources/Developer/DeveloperTools/DeveloperE2ei/DeveloperE2eiViewModel.swift @@ -48,6 +48,7 @@ final class DeveloperE2eiViewModel: ObservableObject { // MARK: - Actions + @MainActor func enrollCertificate() { guard let session = userSession, @@ -56,20 +57,68 @@ final class DeveloperE2eiViewModel: ObservableObject { let e2eiCertificateUseCase = session.enrollE2EICertificate as? EnrollE2EICertificateUseCase let oauthUseCase = OAuthUseCase(targetViewController: { topmostViewController }) - - Task { + let enrollmentFlow = E2EIEnrollmentFlow( + oauthUseCase: oauthUseCase, + targetVC: { topmostViewController } + ) + + Task { @MainActor in + enrollmentFlow.showActivityIndicator() + defer { enrollmentFlow.dismissActivityIndicator() } do { let expirySec = UInt32(certificateExpirationTime) - _ = try await e2eiCertificateUseCase?.invoke( - authenticate: oauthUseCase.invoke, + guard let certificateDetails = try await e2eiCertificateUseCase?.invoke( + authenticate: enrollmentFlow.authenticate, expirySec: expirySec - ) + ) else { return } + + enrollmentFlow.dismissActivityIndicator() + + let successVC = SuccessfulCertificateEnrollmentViewController() + successVC.certificateDetails = certificateDetails + successVC.onOkTapped = { viewController in + viewController.dismiss(animated: true) + } + successVC.presentOverAll() } catch { WireLogger.e2ei.error("failed to enroll e2ei: \(error)") } } } + @MainActor + func showUpdateCertificateAlert(canRemindLater: Bool) { + typealias E2EIUpdateStrings = L10n.Localizable.UpdateCertificate.Alert + + guard let developerToolsViewController = UIApplication.shared.topmostViewController(onlyFullScreen: false) + else { + return + } + + developerToolsViewController.dismiss(animated: true) { + guard let presentingViewController = UIApplication.shared.topmostViewController(onlyFullScreen: false) + else { + return + } + + let alert = UIAlertController.alertForE2EIChangeWithActions( + title: E2EIUpdateStrings.title, + message: canRemindLater ? E2EIUpdateStrings.message : E2EIUpdateStrings.expiredMessage, + enrollButtonText: E2EIUpdateStrings.title, + canRemindLater: canRemindLater + ) { action in + switch action { + case .getCertificate: + self.enrollCertificate() + case .remindLater, .learnMore: + break + } + } + + presentingViewController.present(alert, animated: true) + } + } + func removeAllExpirationDates() { guard let crlExpirationDatesRepository else { return } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Blacklist/BlockerViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Blacklist/BlockerViewController.swift index 55c5b761b4f..7714d2aca81 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Blacklist/BlockerViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Blacklist/BlockerViewController.swift @@ -353,17 +353,30 @@ extension BlockerViewController { } let oauthUseCase = OAuthUseCase(targetViewController: { rootViewController }) + let enrollmentFlow = E2EIEnrollmentFlow( + oauthUseCase: oauthUseCase, + targetVC: { rootViewController } + ) + + enrollmentFlow.showActivityIndicator() + + do { + let certificateChain = try await activeUserSession + .enrollE2EICertificate + .invoke(authenticate: enrollmentFlow.authenticate) - let certificateChain = try await activeUserSession - .enrollE2EICertificate - .invoke(authenticate: oauthUseCase.invoke) + enrollmentFlow.dismissActivityIndicator() - let successEnrollmentViewController = SuccessfulCertificateEnrollmentViewController() - successEnrollmentViewController.certificateDetails = certificateChain - successEnrollmentViewController.onOkTapped = { viewController in - viewController.dismiss(animated: true) + let successEnrollmentViewController = SuccessfulCertificateEnrollmentViewController() + successEnrollmentViewController.certificateDetails = certificateChain + successEnrollmentViewController.onOkTapped = { viewController in + viewController.dismiss(animated: true) + } + successEnrollmentViewController.presentOverAll() + } catch { + enrollmentFlow.dismissActivityIndicator() + throw error } - successEnrollmentViewController.presentOverAll() } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceDetailsViewActionsHandler.swift b/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceDetailsViewActionsHandler.swift index c321caf8b09..9ac56bb67cd 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceDetailsViewActionsHandler.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceDetailsViewActionsHandler.swift @@ -141,8 +141,16 @@ final class DeviceDetailsViewActionsHandler: DeviceDetailsViewActions, Observabl throw DeviceDetailsActionsError.failedAction(errorDescription) } let oauthUseCase = OAuthUseCase(targetViewController: { topmostViewController }) + let enrollmentFlow = E2EIEnrollmentFlow( + oauthUseCase: oauthUseCase, + targetVC: { topmostViewController } + ) + + enrollmentFlow.showActivityIndicator() + defer { enrollmentFlow.dismissActivityIndicator() } + return try await e2eiCertificateEnrollment.invoke( - authenticate: oauthUseCase.invoke + authenticate: enrollmentFlow.authenticate ) } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceInfoViewModel.swift b/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceInfoViewModel.swift index d25dc6a3a4c..68d23797622 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceInfoViewModel.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/DeviceView/DeviceInfoViewModel.swift @@ -141,14 +141,12 @@ final class DeviceInfoViewModel: ObservableObject { @MainActor func enrollClient() async { - isActionInProgress = true do { let certificateChain = try await actionsHandler.enrollClient() showCertificateUpdateSuccess?(certificateChain) } catch { showEnrollmentCertificateError = true } - isActionInProgress = false } @MainActor diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EIEnrollmentFlow.swift b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EIEnrollmentFlow.swift new file mode 100644 index 00000000000..b6cb7b14aca --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EIEnrollmentFlow.swift @@ -0,0 +1,60 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import UIKit +import WireRequestStrategy +import WireReusableUIComponents + +@MainActor +final class E2EIEnrollmentFlow { + + private let oauthUseCase: OAuthUseCaseInterface + private let targetVC: () -> UIViewController + private var activityIndicator: BlockingActivityIndicator? + + init( + oauthUseCase: OAuthUseCaseInterface, + targetVC: @escaping () -> UIViewController + ) { + self.oauthUseCase = oauthUseCase + self.targetVC = targetVC + } + + func showActivityIndicator() { + guard + activityIndicator == nil, + let window = targetVC().view.window + else { return } + + activityIndicator = BlockingActivityIndicator(view: window) + activityIndicator?.start() + } + + func dismissActivityIndicator() { + activityIndicator?.stop() + activityIndicator = nil + } + + func authenticate(_ parameters: OAuthParameters) async throws -> OAuthResponse { + try await oauthUseCase.invoke( + parameters: parameters, + onWebViewPresenting: { [weak self] in self?.dismissActivityIndicator() }, + onWebViewDismissed: { [weak self] in self?.showActivityIndicator() } + ) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EINotificationActionsHandler.swift b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EINotificationActionsHandler.swift index f97cc9f2949..edec61f06ec 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EINotificationActionsHandler.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/E2EINotificationActionsHandler.swift @@ -46,11 +46,12 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { private var e2eIdentityCertificateUpdateStatus: E2EIdentityCertificateUpdateStatusUseCaseProtocol? private let selfClientCertificateProvider: SelfClientCertificateProviderProtocol private var isUpdateMode: Bool = false + private var isUpdateFlowActive: Bool = false private let targetVC: () -> UIViewController private var observer: NSObjectProtocol? - private weak var alertForE2EIChange: UIAlertController? + private var enrollmentFlow: E2EIEnrollmentFlow? private let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -98,13 +99,22 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { NotificationCenter.default.removeObserver(observer) } + @MainActor func getCertificate() async { + isUpdateFlowActive = true let oauthUseCase = OAuthUseCase(targetViewController: targetVC) + let enrollmentFlow = E2EIEnrollmentFlow(oauthUseCase: oauthUseCase, targetVC: targetVC) + self.enrollmentFlow = enrollmentFlow + enrollmentFlow.showActivityIndicator() + do { - let certificateDetails = try await enrollCertificateUseCase.invoke(authenticate: oauthUseCase.invoke) + let certificateDetails = try await enrollCertificateUseCase + .invoke(authenticate: enrollmentFlow.authenticate) stopCertificateEnrollmentSnoozerUseCase.invoke() - await confirmSuccessfulEnrollment(certificateDetails) + confirmSuccessfulEnrollment(certificateDetails) } catch { + enrollmentFlow.dismissActivityIndicator() + self.enrollmentFlow = nil let canCancel = gracePeriodEndDate == nil || gracePeriodEndDate?.isInThePast == false await showGetCertificateErrorAlert(canCancel: canCancel, retry: getCertificate) } @@ -165,6 +175,7 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { } } cancelled: { [weak self] in + self?.isUpdateFlowActive = false self?.isUpdateMode = false } await presentScreen(viewController: alert) @@ -172,6 +183,9 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { @MainActor private func confirmSuccessfulEnrollment(_ certificateDetails: String) { + isUpdateFlowActive = false + enrollmentFlow?.dismissActivityIndicator() + enrollmentFlow = nil lastE2EIdentityUpdateAlertDateRepository?.storeLastAlertDate(Date.now) stopCertificateEnrollmentSnoozerUseCase.invoke() let successScreen = SuccessfulCertificateEnrollmentViewController(isUpdateMode: isUpdateMode) @@ -180,7 +194,6 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { viewController.dismiss(animated: true) self.isUpdateMode = false } - presentScreen(viewController: successScreen) } @@ -194,14 +207,15 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { private func showUpdateE2EIdentityCertificateAlert(canRemindLater: Bool = true) { typealias E2EIUpdateStrings = L10n.Localizable.UpdateCertificate.Alert - guard alertForE2EIChange == nil else { return } + guard !isUpdateFlowActive else { return } + isUpdateFlowActive = true let alert = UIAlertController.alertForE2EIChangeWithActions( title: E2EIUpdateStrings.title, message: canRemindLater ? E2EIUpdateStrings.message : E2EIUpdateStrings.expiredMessage, enrollButtonText: E2EIUpdateStrings.title, canRemindLater: canRemindLater - ) { action in + ) { [weak self] action in switch action { case .getCertificate: @@ -209,14 +223,14 @@ final class E2EINotificationActionsHandler: E2EINotificationActions { await self?.getCertificate() } case .remindLater: + self?.isUpdateFlowActive = false Task { [weak self] in await self?.snoozeReminder() } case .learnMore: - break + self?.isUpdateFlowActive = false } } - alertForE2EIChange = alert lastE2EIdentityUpdateAlertDateRepository?.storeLastAlertDate(Date.now) presentScreen(viewController: alert) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/LifecycleAwareUserAgent.swift b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/LifecycleAwareUserAgent.swift new file mode 100644 index 00000000000..235ebca1a49 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/LifecycleAwareUserAgent.swift @@ -0,0 +1,71 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import AppAuth +import Foundation + +/// Wraps an `OIDExternalUserAgent` with callbacks fired around its presentation +/// lifecycle. `onPresented` runs after the wrapped agent has successfully presented +/// its UI; `onDismissed` runs after the wrapped agent's dismissal completes. +final class LifecycleAwareUserAgent: NSObject, OIDExternalUserAgent { + + private let wrapped: OIDExternalUserAgent + private let onPresented: (@MainActor () -> Void)? + private let onDismissed: (@MainActor () -> Void)? + + init( + wrapping wrapped: OIDExternalUserAgent, + onPresented: (@MainActor () -> Void)?, + onDismissed: (@MainActor () -> Void)? + ) { + self.wrapped = wrapped + self.onPresented = onPresented + self.onDismissed = onDismissed + } + + // `OIDExternalUserAgent` is an Obj-C protocol with no actor annotation, so Swift treats `present` and + // `dismiss` as callable from any context. + // + // We can't annotate it `@MainActor`, but AppAuth always calls it on the main thread since it deals with UI. + // + // `assumeIsolated` is tells the compiler we're already on the main actor, so we can call the + // `@MainActor`-bound `onPresented()` and `onDismiss()` closures synchronously. + + func present( + _ request: any OIDExternalUserAgentRequest, + session: any OIDExternalUserAgentSession + ) -> Bool { + let didPresent = wrapped.present(request, session: session) + if didPresent, let onPresented { + MainActor.assumeIsolated { onPresented() } + } + return didPresent + } + + func dismiss( + animated: Bool, + completion: @escaping () -> Void + ) { + wrapped.dismiss(animated: animated) { [weak self] in + if let onDismissed = self?.onDismissed { + MainActor.assumeIsolated { onDismissed() } + } + completion() + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/OAuthUseCase.swift b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/OAuthUseCase.swift index 61c87f543a3..abc6c338f0c 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/OAuthUseCase.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/E2EIdentity/OAuthUseCase.swift @@ -24,9 +24,14 @@ import WireRequestStrategy import WireSystem import WireUtilities +// sourcery: AutoMockable protocol OAuthUseCaseInterface { - func invoke(parameters: OAuthParameters) async throws -> OAuthResponse + func invoke( + parameters: OAuthParameters, + onWebViewPresenting: (@MainActor () -> Void)?, + onWebViewDismissed: (@MainActor () -> Void)? + ) async throws -> OAuthResponse } @@ -40,7 +45,11 @@ class OAuthUseCase: OAuthUseCaseInterface { self.targetViewController = targetViewController } - func invoke(parameters: OAuthParameters) async throws -> OAuthResponse { + func invoke( + parameters: OAuthParameters, + onWebViewPresenting: (@MainActor () -> Void)? = nil, + onWebViewDismissed: (@MainActor () -> Void)? = nil + ) async throws -> OAuthResponse { logger.info("invoke authentication flow") guard let redirectURI = URL(string: "wire://e2ei/oauth2redirect") else { @@ -76,7 +85,11 @@ class OAuthUseCase: OAuthUseCaseInterface { } } - return try await execute(authorizationRequest: request) + return try await execute( + authorizationRequest: request, + onWebViewPresenting: onWebViewPresenting, + onWebViewDismissed: onWebViewDismissed + ) } private func createAdditionalParameters(with keyauth: String, acmeAudience: String) -> [String: String]? { @@ -112,8 +125,15 @@ class OAuthUseCase: OAuthUseCaseInterface { } @MainActor - private func execute(authorizationRequest: OIDAuthorizationRequest) async throws -> OAuthResponse { - let userAgent = try userAgent() + private func execute( + authorizationRequest: OIDAuthorizationRequest, + onWebViewPresenting: (@MainActor () -> Void)?, + onWebViewDismissed: (@MainActor () -> Void)? + ) async throws -> OAuthResponse { + let userAgent = try userAgent( + onWebViewPresenting: onWebViewPresenting, + onWebViewDismissed: onWebViewDismissed + ) return try await withCheckedThrowingContinuation { [weak self] continuation in self?.currentAuthorizationFlow = OIDAuthState.authState( byPresenting: authorizationRequest, @@ -140,19 +160,27 @@ class OAuthUseCase: OAuthUseCaseInterface { } } - private func userAgent() throws -> OIDExternalUserAgent { + private func userAgent( + onWebViewPresenting: (@MainActor () -> Void)?, + onWebViewDismissed: (@MainActor () -> Void)? + ) throws -> OIDExternalUserAgent { + let baseAgent: OIDExternalUserAgent if SecurityFlags.useEmbeddedIDPUserAgent.isEnabled { - return WebViewUserAgent(targetViewController: targetViewController()) + baseAgent = WebViewUserAgent(targetViewController: targetViewController()) } else { - guard let userAgent = OIDExternalUserAgentIOS( + guard let agent = OIDExternalUserAgentIOS( presenting: targetViewController(), prefersEphemeralSession: true ) else { throw OAuthError.missingOIDExternalUserAgent } - - return userAgent + baseAgent = agent } + return LifecycleAwareUserAgent( + wrapping: baseAgent, + onPresented: onWebViewPresenting, + onDismissed: onWebViewDismissed + ) } }