diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 25f3bff69..9824f11e4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 00091F143129B9A4904563C4 /* IdentityConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */; }; 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C070FD43DC6BF4E50217965A /* LocalizationTests.swift */; }; 00C3023B6DF55024D8876B76 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3D8BEEFCA07BEA43F4F4BF77 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 012D9DDCDE6278E4E0CDCC0F /* LinkNewDeviceFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94EE2C5F0A06F146BBE3A1B1 /* LinkNewDeviceFlowCoordinator.swift */; }; @@ -2942,6 +2943,7 @@ EBEB8D9F4940E161B18FE4BC /* UITestsNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsNotificationCenter.swift; sourceTree = ""; }; EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsViewModelTests.swift; sourceTree = ""; }; ECB836DD8BE31931F51B8AC9 /* EncryptionSettingsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionSettingsFlowCoordinator.swift; sourceTree = ""; }; + ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenViewModelTests.swift; sourceTree = ""; }; ECD5FCBA169B6A82F501CA1B /* AnalyticsSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; ECE03E834CC8C2721899E6AC /* StaticLocationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationSheet.swift; sourceTree = ""; }; ECEC9A622AA2F8DBE928AC78 /* FloatingDateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingDateBadge.swift; sourceTree = ""; }; @@ -4920,6 +4922,7 @@ EA4D639E27D5882A6A71AECF /* GlobalSearchScreenViewModelTests.swift */, B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */, 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */, + ECBBCD7444C2B515D02EC0E8 /* IdentityConfirmationScreenViewModelTests.swift */, 845DDBDE5A0887E73D38B826 /* InviteUsersViewModelTests.swift */, DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */, FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */, @@ -7876,6 +7879,7 @@ EE56238683BC3ECA9BA00684 /* GlobalSearchScreenViewModelTests.swift in Sources */, 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, + 00091F143129B9A4904563C4 /* IdentityConfirmationScreenViewModelTests.swift in Sources */, A23B8B27A1436A1049EEF68E /* InfoPlistReader.swift in Sources */, A216C83ADCF32BA5EF8A6FBC /* InviteUsersViewModelTests.swift in Sources */, 7C0E29E0279866C62EC67A28 /* JoinRoomScreenViewModelTests.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift index a07e73ff9..b2fe73e87 100644 --- a/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift @@ -13,7 +13,7 @@ import SwiftState enum OnboardingFlowCoordinatorAction { case requestPresentation(animated: Bool) case dismiss - case logout + case logoutConfirmed } class OnboardingFlowCoordinator: FlowCoordinatorProtocol { @@ -260,8 +260,8 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.nextSkippingIdentityConfirmed) case .reset: startEncryptionResetFlow() - case .logout: - actionsSubject.send(.logout) + case .logoutConfirmed: + actionsSubject.send(.logoutConfirmed) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index c9476bc57..37eabbceb 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -275,8 +275,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { navigationTabCoordinator.setFullScreenCoverCoordinator(onboardingStackCoordinator, animated: animated) case .dismiss: navigationTabCoordinator.setFullScreenCoverCoordinator(nil) - case .logout: - logout() + case .logoutConfirmed: + actionsSubject.send(.logout) } } .store(in: &cancellables) @@ -494,7 +494,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } guard isLastDevice else { - logout() + navigationRootCoordinator.alertInfo = .init(id: .init(), + title: L10n.screenSignoutConfirmationDialogTitle, + message: L10n.screenSignoutConfirmationDialogContent, + primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in + self?.actionsSubject.send(.logout) + }) return } @@ -525,15 +530,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentSecureBackupLogoutConfirmationScreen() } - private func logout() { - navigationRootCoordinator.alertInfo = .init(id: .init(), - title: L10n.screenSignoutConfirmationDialogTitle, - message: L10n.screenSignoutConfirmationDialogContent, - primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in - self?.actionsSubject.send(.logout) - }) - } - private func presentSecureBackupLogoutConfirmationScreen() { let coordinator = SecureBackupLogoutConfirmationScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController, homeserverReachabilityPublisher: userSession.clientProxy.homeserverReachabilityPublisher)) diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenCoordinator.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenCoordinator.swift index b593a6208..011df2b8d 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenCoordinator.swift @@ -21,7 +21,7 @@ enum IdentityConfirmationScreenCoordinatorAction { /// Only possible in debug builds. case skip case reset - case logout + case logoutConfirmed } final class IdentityConfirmationScreenCoordinator: CoordinatorProtocol { @@ -57,8 +57,8 @@ final class IdentityConfirmationScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.skip) case .reset: actionsSubject.send(.reset) - case .logout: - actionsSubject.send(.logout) + case .logoutConfirmed: + actionsSubject.send(.logoutConfirmed) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift index a46b87264..8d2ef073a 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenModels.swift @@ -14,7 +14,7 @@ enum IdentityConfirmationScreenViewModelAction { /// Only possible in debug builds. case skip case reset - case logout + case logoutConfirmed } struct IdentityConfirmationScreenViewState: BindableState { @@ -25,6 +25,16 @@ struct IdentityConfirmationScreenViewState: BindableState { var availableActions: [AvailableActions]? let learnMoreURL: URL + + var bindings = IdentityConfirmationScreenBindings() +} + +struct IdentityConfirmationScreenBindings { + var alertInfo: AlertInfo? +} + +enum IdentityConfirmationScreenAlertType { + case logout } enum IdentityConfirmationScreenViewAction { diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift index d8e3e51e4..0d55b9385 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/IdentityConfirmationScreenViewModel.swift @@ -49,7 +49,7 @@ class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelTy case .reset: actionsSubject.send(.reset) case .logout: - actionsSubject.send(.logout) + confirmLogout() } } @@ -88,6 +88,19 @@ class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelTy state.availableActions = availableActions } + private func confirmLogout() { + // We need to show the confirmation within this flow as letting the UserSession flow do it results in the + // onboarding flow's modal being dismissed (by SwiftUI, not us). However we don't need any of the additional + // checks made in the UserSession flow as the user's account isn't verified so there's no much they can do unless + // they complete verification. + state.bindings.alertInfo = .init(id: .logout, + title: L10n.screenSignoutConfirmationDialogTitle, + message: L10n.screenSignoutConfirmationDialogContent, + primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in + self?.actionsSubject.send(.logoutConfirmed) + }) + } + private static let loadingIndicatorIdentifier = "\(IdentityConfirmationScreenViewModel.self)-Loading" private func showLoadingIndicator() { diff --git a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift index 2b406d50f..378b960ca 100644 --- a/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift +++ b/ElementX/Sources/Screens/Onboarding/IdentityConfirmationScreen/View/IdentityConfirmationScreen.swift @@ -10,7 +10,7 @@ import Compound import SwiftUI struct IdentityConfirmationScreen: View { - let context: IdentityConfirmationScreenViewModel.Context + @Bindable var context: IdentityConfirmationScreenViewModel.Context var shouldShowSkipButton: Bool { #if DEBUG @@ -31,6 +31,7 @@ struct IdentityConfirmationScreen: View { .backgroundStyle(.compound.bgCanvasDefault) .navigationBarBackButtonHidden(true) .interactiveDismissDisabled() + .alert(item: $context.alertInfo) } // MARK: - Private diff --git a/UnitTests/Sources/IdentityConfirmationScreenViewModelTests.swift b/UnitTests/Sources/IdentityConfirmationScreenViewModelTests.swift new file mode 100644 index 000000000..5b5f2f07c --- /dev/null +++ b/UnitTests/Sources/IdentityConfirmationScreenViewModelTests.swift @@ -0,0 +1,115 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +@testable import ElementX +import Testing + +@MainActor +struct IdentityConfirmationScreenViewModelTests { + var securityStateSubject: CurrentValueSubject! + + var viewModel: IdentityConfirmationScreenViewModel! + var context: IdentityConfirmationScreenViewModel.Context { + viewModel.context + } + + @Test + mutating func logoutShowsConfirmation() async throws { + setupViewModel() + + #expect(context.alertInfo == nil) + + context.send(viewAction: .logout) + + let alertInfo = try #require(context.alertInfo) + + let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .logoutConfirmed } + alertInfo.primaryButton.action?() + try await deferred.fulfill() + } + + // MARK: - Available Actions + + @Test + mutating func availableActionsWithDevicesAndRecovery() async throws { + setupViewModel(hasDevicesToVerifyAgainst: true) + #expect(context.viewState.availableActions == nil) + + let deferred = deferFulfillment(context.observe(\.viewState.availableActions)) { $0 != nil } + securityStateSubject.send(.init(verificationState: .unverified, recoveryState: .enabled)) + try await deferred.fulfill() + + let availableActions = try #require(context.viewState.availableActions) + #expect(availableActions == [.interactiveVerification, .recovery]) + } + + @Test + mutating func availableActionsWithDevices() async throws { + setupViewModel(hasDevicesToVerifyAgainst: true) + #expect(context.viewState.availableActions == nil) + + let deferred = deferFulfillment(context.observe(\.viewState.availableActions)) { $0 != nil } + securityStateSubject.send(.init(verificationState: .unverified, recoveryState: .disabled)) + try await deferred.fulfill() + + let availableActions = try #require(context.viewState.availableActions) + #expect(availableActions == [.interactiveVerification]) + } + + @Test + mutating func availableActionsWithRecovery() async throws { + setupViewModel(hasDevicesToVerifyAgainst: false) + #expect(context.viewState.availableActions == nil) + + let deferred = deferFulfillment(context.observe(\.viewState.availableActions)) { $0 != nil } + securityStateSubject.send(.init(verificationState: .unverified, recoveryState: .enabled)) + try await deferred.fulfill() + + let availableActions = try #require(context.viewState.availableActions) + #expect(availableActions == [.recovery]) + } + + @Test + mutating func availableActionsWithoutDevicesOrRecovery() async throws { + setupViewModel(hasDevicesToVerifyAgainst: false) + #expect(context.viewState.availableActions == nil) + + let deferred = deferFulfillment(context.observe(\.viewState.availableActions)) { $0 != nil } + securityStateSubject.send(.init(verificationState: .unverified, recoveryState: .disabled)) + try await deferred.fulfill() + + let availableActions = try #require(context.viewState.availableActions) + #expect(availableActions.isEmpty) + } + + @Test + mutating func availableActionsWhileSecurityStateIsPending() async throws { + setupViewModel(hasDevicesToVerifyAgainst: true) + + let deferred = deferFailure(context.observe(\.viewState.availableActions), timeout: .seconds(1)) { $0 != nil } + try await deferred.fulfill() + + #expect(context.viewState.availableActions == nil) + } + + // MARK: - Private + + mutating func setupViewModel(hasDevicesToVerifyAgainst: Bool = true) { + let initialState = SessionSecurityState(verificationState: .unverified, recoveryState: .unknown) + securityStateSubject = CurrentValueSubject(initialState) + + let clientProxy = ClientProxyMock(.init()) + clientProxy.hasDevicesToVerifyAgainstReturnValue = .success(hasDevicesToVerifyAgainst) + let userSession = UserSessionMock(.init(clientProxy: clientProxy)) + userSession.sessionSecurityStatePublisher = securityStateSubject.asCurrentValuePublisher() + + viewModel = IdentityConfirmationScreenViewModel(userSession: userSession, + appSettings: AppSettings(), + userIndicatorController: UserIndicatorControllerMock()) + } +}