diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 8f07ad2c6..e281aa949 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -361,12 +361,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private func runLogoutFlow() async { let secureBackupController = userSession.clientProxy.secureBackupController - guard case let .success(isLastSession) = await secureBackupController.isLastSession() else { + guard case let .success(isLastDevice) = await userSession.clientProxy.isOnlyDeviceLeft() else { ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init()) return } - guard isLastSession else { + guard isLastDevice else { ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), title: L10n.screenSignoutConfirmationDialogTitle, message: L10n.screenSignoutConfirmationDialogContent, @@ -376,7 +376,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { return } - guard secureBackupController.recoveryKeyState.value == .enabled else { + guard secureBackupController.recoveryState.value == .enabled else { ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), title: L10n.screenSignoutRecoveryDisabledTitle, message: L10n.screenSignoutRecoveryDisabledSubtitle, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index c4d23f69c..77c8711da 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2363,11 +2363,11 @@ class RoomTimelineProviderMock: RoomTimelineProviderProtocol { } class SecureBackupControllerMock: SecureBackupControllerProtocol { - var recoveryKeyState: CurrentValuePublisher { - get { return underlyingRecoveryKeyState } - set(value) { underlyingRecoveryKeyState = value } + var recoveryState: CurrentValuePublisher { + get { return underlyingRecoveryState } + set(value) { underlyingRecoveryState = value } } - var underlyingRecoveryKeyState: CurrentValuePublisher! + var underlyingRecoveryState: CurrentValuePublisher! var keyBackupState: CurrentValuePublisher { get { return underlyingKeyBackupState } set(value) { underlyingKeyBackupState = value } @@ -2446,23 +2446,6 @@ class SecureBackupControllerMock: SecureBackupControllerProtocol { return confirmRecoveryKeyReturnValue } } - //MARK: - isLastSession - - var isLastSessionCallsCount = 0 - var isLastSessionCalled: Bool { - return isLastSessionCallsCount > 0 - } - var isLastSessionReturnValue: Result! - var isLastSessionClosure: (() async -> Result)? - - func isLastSession() async -> Result { - isLastSessionCallsCount += 1 - if let isLastSessionClosure = isLastSessionClosure { - return await isLastSessionClosure() - } else { - return isLastSessionReturnValue - } - } //MARK: - waitForKeyBackupUpload var waitForKeyBackupUploadCallsCount = 0 diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 8f311cb96..6c7a19164 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -75,33 +75,21 @@ enum HomeScreenRoomListMode: CustomStringConvertible { } } +enum SecurityBannerMode { + case none + case dismissed + case sessionVerification + case recoveryKeyConfirmation +} + struct HomeScreenViewState: BindableState { let userID: String var userDisplayName: String? var userAvatarURL: URL? - var isSessionVerified: Bool? - var hasSessionVerificationBannerBeenDismissed = false - var showSessionVerificationBanner: Bool { - guard let isSessionVerified else { - return false - } + var securityBannerMode = SecurityBannerMode.none + var requiresExtraAccountSetup = false - return !isSessionVerified && !hasSessionVerificationBannerBeenDismissed - } - - var requiresSecureBackupSetup = false - - var needsRecoveryKeyConfirmation = false - var hasRecoveryKeyConfirmationBannerBeenDismissed = false - var showRecoveryKeyConfirmationBanner: Bool { - guard let isSessionVerified else { - return false - } - - return isSessionVerified && needsRecoveryKeyConfirmation && !hasRecoveryKeyConfirmationBannerBeenDismissed - } - var rooms: [HomeScreenRoom] = [] var roomListMode: HomeScreenRoomListMode = .skeletons diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index ca9f78911..809fcc8d2 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -61,20 +61,35 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol .weakAssign(to: \.state.userDisplayName, on: self) .store(in: &cancellables) - userSession.sessionVerificationState + userSession.sessionSecurityStatePublisher .receive(on: DispatchQueue.main) - .weakAssign(to: \.state.isSessionVerified, on: self) - .store(in: &cancellables) - - userSession.clientProxy.secureBackupController.recoveryKeyState - .receive(on: DispatchQueue.main) - .sink { [weak self] recoveryKeyState in + .sink { [weak self] securityState in guard let self else { return } - let requiresSecureBackupSetup = recoveryKeyState == .disabled || recoveryKeyState == .incomplete - state.requiresSecureBackupSetup = requiresSecureBackupSetup - - state.needsRecoveryKeyConfirmation = recoveryKeyState == .incomplete + switch (securityState.verificationState, securityState.recoveryState) { + case (.unverified, _): + state.requiresExtraAccountSetup = true + if state.securityBannerMode != .dismissed { + state.securityBannerMode = .sessionVerification + } + case (.unverifiedLastSession, .incomplete): + state.requiresExtraAccountSetup = true + if state.securityBannerMode != .dismissed { + state.securityBannerMode = .recoveryKeyConfirmation + } + case (.verified, .disabled): + state.requiresExtraAccountSetup = true + state.securityBannerMode = .none + case (.verified, .incomplete): + state.requiresExtraAccountSetup = true + + if state.securityBannerMode != .dismissed { + state.securityBannerMode = .recoveryKeyConfirmation + } + default: + state.securityBannerMode = .none + state.requiresExtraAccountSetup = false + } } .store(in: &cancellables) @@ -149,9 +164,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol case .confirmRecoveryKey: actionsSubject.send(.presentSecureBackupSettings) case .skipSessionVerification: - state.hasSessionVerificationBannerBeenDismissed = true + state.securityBannerMode = .dismissed case .skipRecoveryKeyConfirmation: - state.hasRecoveryKeyConfirmationBannerBeenDismissed = true + state.securityBannerMode = .dismissed case .updateVisibleItemRange(let range, let isScrolling): visibleItemRangePublisher.send((range, isScrolling)) case .startChat: diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift index f02689c97..901953e41 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift @@ -116,10 +116,13 @@ struct HomeScreenContent: View { filters } - if context.viewState.showSessionVerificationBanner { + switch context.viewState.securityBannerMode { + case .sessionVerification: HomeScreenSessionVerificationBanner(context: context) - } else if context.viewState.showRecoveryKeyConfirmationBanner { + case .recoveryKeyConfirmation: HomeScreenRecoveryKeyConfirmationBanner(context: context) + default: + EmptyView() } if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused { diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenUserMenuButton.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenUserMenuButton.swift index 5d67b59c8..26f03a5d2 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenUserMenuButton.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenUserMenuButton.swift @@ -29,7 +29,7 @@ struct HomeScreenUserMenuButton: View { Label { Text(L10n.commonSettings) } icon: { - if context.viewState.requiresSecureBackupSetup, context.viewState.isSessionVerified == true { + if context.viewState.requiresExtraAccountSetup { CompoundIcon(asset: Asset.Images.settingsIconWithBadge) } else { CompoundIcon(\.settings) @@ -50,7 +50,7 @@ struct HomeScreenUserMenuButton: View { avatarSize: .user(on: .home), imageProvider: context.imageProvider) .accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar) - .overlayBadge(10, isBadged: context.viewState.requiresSecureBackupSetup && context.viewState.isSessionVerified == true) + .overlayBadge(10, isBadged: context.viewState.requiresExtraAccountSetup) .compositingGroup() } .accessibilityLabel(L10n.a11yUserMenu) diff --git a/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/SecureBackupRecoveryKeyScreenViewModel.swift b/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/SecureBackupRecoveryKeyScreenViewModel.swift index 79da34e25..53175d65e 100644 --- a/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/SecureBackupRecoveryKeyScreenViewModel.swift +++ b/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/SecureBackupRecoveryKeyScreenViewModel.swift @@ -32,9 +32,9 @@ class SecureBackupRecoveryKeyScreenViewModel: SecureBackupRecoveryKeyScreenViewM self.secureBackupController = secureBackupController self.userIndicatorController = userIndicatorController - super.init(initialViewState: .init(mode: secureBackupController.recoveryKeyState.value.viewMode, bindings: .init())) + super.init(initialViewState: .init(mode: secureBackupController.recoveryState.value.viewMode, bindings: .init())) - secureBackupController.recoveryKeyState + secureBackupController.recoveryState .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak userIndicatorController] state in let loadingIndicatorIdentifier = "SecureBackupRecoveryKeyScreenLoading" @@ -100,7 +100,7 @@ class SecureBackupRecoveryKeyScreenViewModel: SecureBackupRecoveryKeyScreenViewM } } -extension SecureBackupRecoveryKeyState { +extension SecureBackupRecoveryState { var viewMode: SecureBackupRecoveryKeyScreenViewMode { switch self { case .disabled: diff --git a/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/View/SecureBackupRecoveryKeyScreen.swift b/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/View/SecureBackupRecoveryKeyScreen.swift index 03df0d9ae..666e19031 100644 --- a/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/View/SecureBackupRecoveryKeyScreen.swift +++ b/ElementX/Sources/Screens/SecureBackup/SecureBackupRecoveryKeyScreen/View/SecureBackupRecoveryKeyScreen.swift @@ -65,6 +65,20 @@ struct SecureBackupRecoveryKeyScreen: View { private var footer: some View { switch context.viewState.mode { case .setupRecovery, .changeRecovery: + recoveryCreatedActionButtons + case .fixRecovery: + Button { + context.send(viewAction: .confirmKey) + } label: { + Text(L10n.actionConfirm) + } + .buttonStyle(.compound(.primary)) + .disabled(context.confirmationRecoveryKey.isEmpty) + } + } + + private var recoveryCreatedActionButtons: some View { + VStack(spacing: 8.0) { if let recoveryKey = context.viewState.recoveryKey { ShareLink(item: recoveryKey) { Label(L10n.screenRecoveryKeySaveAction, icon: \.download) @@ -82,14 +96,6 @@ struct SecureBackupRecoveryKeyScreen: View { } .buttonStyle(.compound(.primary)) .disabled(context.viewState.recoveryKey == nil || context.viewState.doneButtonEnabled == false) - case .fixRecovery: - Button { - context.send(viewAction: .confirmKey) - } label: { - Text(L10n.actionConfirm) - } - .buttonStyle(.compound(.primary)) - .disabled(context.confirmationRecoveryKey.isEmpty) } } @@ -204,9 +210,9 @@ struct SecureBackupRecoveryKeyScreen: View { // MARK: - Previews struct SecureBackupRecoveryKeyScreen_Previews: PreviewProvider, TestablePreview { - static let setupViewModel = viewModel(recoveryKeyState: .enabled) - static let notSetUpViewModel = viewModel(recoveryKeyState: .disabled) - static let incompleteViewModel = viewModel(recoveryKeyState: .incomplete) + static let setupViewModel = viewModel(recoveryState: .enabled) + static let notSetUpViewModel = viewModel(recoveryState: .disabled) + static let incompleteViewModel = viewModel(recoveryState: .incomplete) static var previews: some View { NavigationStack { @@ -225,9 +231,9 @@ struct SecureBackupRecoveryKeyScreen_Previews: PreviewProvider, TestablePreview .previewDisplayName("Incomplete") } - static func viewModel(recoveryKeyState: SecureBackupRecoveryKeyState) -> SecureBackupRecoveryKeyScreenViewModelType { + static func viewModel(recoveryState: SecureBackupRecoveryState) -> SecureBackupRecoveryKeyScreenViewModelType { let backupController = SecureBackupControllerMock() - backupController.underlyingRecoveryKeyState = CurrentValueSubject(recoveryKeyState).asCurrentValuePublisher() + backupController.underlyingRecoveryState = CurrentValueSubject(recoveryState).asCurrentValuePublisher() return SecureBackupRecoveryKeyScreenViewModel(secureBackupController: backupController, userIndicatorController: UserIndicatorControllerMock()) } diff --git a/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenModels.swift b/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenModels.swift index 4506e0aef..5a0c90562 100644 --- a/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenModels.swift +++ b/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenModels.swift @@ -23,7 +23,7 @@ enum SecureBackupScreenViewModelAction { struct SecureBackupScreenViewState: BindableState { let chatBackupDetailsURL: URL - var recoveryKeyState = SecureBackupRecoveryKeyState.unknown + var recoveryState = SecureBackupRecoveryState.unknown var keyBackupState = SecureBackupKeyBackupState.unknown var bindings = SecureBackupScreenViewStateBindings() } diff --git a/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenViewModel.swift b/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenViewModel.swift index d4524113a..62aa73e7d 100644 --- a/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenViewModel.swift +++ b/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/SecureBackupScreenViewModel.swift @@ -36,9 +36,9 @@ class SecureBackupScreenViewModel: SecureBackupScreenViewModelType, SecureBackup super.init(initialViewState: .init(chatBackupDetailsURL: chatBackupDetailsURL)) - secureBackupController.recoveryKeyState + secureBackupController.recoveryState .receive(on: DispatchQueue.main) - .weakAssign(to: \.state.recoveryKeyState, on: self) + .weakAssign(to: \.state.recoveryState, on: self) .store(in: &cancellables) secureBackupController.keyBackupState diff --git a/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/View/SecureBackupScreen.swift b/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/View/SecureBackupScreen.swift index fdf6ea264..b74a2b545 100644 --- a/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/View/SecureBackupScreen.swift +++ b/ElementX/Sources/Screens/SecureBackup/SecureBackupScreen/View/SecureBackupScreen.swift @@ -25,7 +25,7 @@ struct SecureBackupScreen: View { Form { // Show recovery options for confirming the recovery key and // getting access to secrets and implicitly the key backup - if context.viewState.recoveryKeyState == .incomplete { + if context.viewState.recoveryState == .incomplete { recoveryKeySection } else { keyBackupSection @@ -93,7 +93,7 @@ struct SecureBackupScreen: View { @ViewBuilder private var recoveryKeySection: some View { Section { - switch context.viewState.recoveryKeyState { + switch context.viewState.recoveryState { case .enabled: ListRow(label: .plain(title: L10n.screenChatBackupRecoveryActionChange), kind: .navigationLink { context.send(viewAction: .recoveryKey) }) @@ -116,7 +116,7 @@ struct SecureBackupScreen: View { @ViewBuilder private var recoveryKeySectionFooter: some View { - switch context.viewState.recoveryKeyState { + switch context.viewState.recoveryState { case .disabled: Text(L10n.screenChatBackupRecoveryActionSetupDescription(InfoPlistReader.main.bundleDisplayName)) case .incomplete: @@ -130,10 +130,10 @@ struct SecureBackupScreen: View { // MARK: - Previews struct SecureBackupScreen_Previews: PreviewProvider, TestablePreview { - static let bothSetupViewModel = viewModel(keyBackupState: .enabled, recoveryKeyState: .enabled) - static let onlyKeyBackupSetUpViewModel = viewModel(keyBackupState: .enabled, recoveryKeyState: .disabled) - static let keyBackupDisabledViewModel = viewModel(keyBackupState: .unknown, recoveryKeyState: .disabled) - static let recoveryIncompleteViewModel = viewModel(keyBackupState: .enabled, recoveryKeyState: .incomplete) + static let bothSetupViewModel = viewModel(keyBackupState: .enabled, recoveryState: .enabled) + static let onlyKeyBackupSetUpViewModel = viewModel(keyBackupState: .enabled, recoveryState: .disabled) + static let keyBackupDisabledViewModel = viewModel(keyBackupState: .unknown, recoveryState: .disabled) + static let recoveryIncompleteViewModel = viewModel(keyBackupState: .enabled, recoveryState: .incomplete) static var previews: some View { Group { @@ -161,10 +161,10 @@ struct SecureBackupScreen_Previews: PreviewProvider, TestablePreview { } static func viewModel(keyBackupState: SecureBackupKeyBackupState, - recoveryKeyState: SecureBackupRecoveryKeyState) -> SecureBackupScreenViewModelType { + recoveryState: SecureBackupRecoveryState) -> SecureBackupScreenViewModelType { let backupController = SecureBackupControllerMock() backupController.underlyingKeyBackupState = CurrentValueSubject(keyBackupState).asCurrentValuePublisher() - backupController.underlyingRecoveryKeyState = CurrentValueSubject(recoveryKeyState).asCurrentValuePublisher() + backupController.underlyingRecoveryState = CurrentValueSubject(recoveryState).asCurrentValuePublisher() return SecureBackupScreenViewModel(secureBackupController: backupController, userIndicatorController: UserIndicatorControllerMock(), diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index 8fb6288cc..d3b4f1e0a 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -34,6 +34,12 @@ enum SettingsScreenViewModelAction { case logout } +enum SettingsScreenSecuritySectionMode { + case none + case sessionVerification + case secureBackup +} + struct SettingsScreenViewState: BindableState { var deviceID: String? var userID: String @@ -41,9 +47,10 @@ struct SettingsScreenViewState: BindableState { var accountSessionsListURL: URL? var userAvatarURL: URL? var userDisplayName: String? - var isSessionVerified: Bool? - var showSecureBackupBadge = false var showDeveloperOptions: Bool + + var securitySectionMode = SettingsScreenSecuritySectionMode.none + var showSecuritySectionBadge = false } enum SettingsScreenViewAction { diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index 50ab440b8..97bd36d43 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -44,17 +44,31 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo .weakAssign(to: \.state.userDisplayName, on: self) .store(in: &cancellables) - userSession.sessionVerificationState + userSession.sessionSecurityStatePublisher .receive(on: DispatchQueue.main) - .weakAssign(to: \.state.isSessionVerified, on: self) - .store(in: &cancellables) - - userSession.clientProxy.secureBackupController.recoveryKeyState - .receive(on: DispatchQueue.main) - .sink { [weak self] state in + .sink { [weak self] securityState in guard let self else { return } - self.state.showSecureBackupBadge = (state == .incomplete || state == .disabled) + switch (securityState.verificationState, securityState.recoveryState) { + case (.unverified, _): + state.showSecuritySectionBadge = true + state.securitySectionMode = .sessionVerification + case (.unverifiedLastSession, .incomplete): + state.showSecuritySectionBadge = true + state.securitySectionMode = .secureBackup + case (.verified, .disabled): + state.showSecuritySectionBadge = true + state.securitySectionMode = .secureBackup + case (.verified, .incomplete): + state.showSecuritySectionBadge = true + state.securitySectionMode = .secureBackup + case (.unknown, _): + state.showSecuritySectionBadge = false + state.securitySectionMode = .none + default: + state.showSecuritySectionBadge = false + state.securitySectionMode = .secureBackup + } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index 7d3957d7a..606fd2b0f 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -80,18 +80,20 @@ struct SettingsScreen: View { @ViewBuilder private var accountSecuritySection: some View { Section { - if let isSessionVerified = context.viewState.isSessionVerified { - if !isSessionVerified { - ListRow(label: .default(title: L10n.actionCompleteVerification, - icon: \.checkCircle), - kind: .button { context.send(viewAction: .sessionVerification) }) - } else { - ListRow(label: .default(title: L10n.commonChatBackup, - icon: \.key), - details: context.viewState.showSecureBackupBadge ? .icon(secureBackupBadge) : nil, - kind: .navigationLink { context.send(viewAction: .secureBackup) }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup) - } + switch context.viewState.securitySectionMode { + case .sessionVerification: + ListRow(label: .default(title: L10n.actionCompleteVerification, + icon: \.checkCircle), + details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil, + kind: .button { context.send(viewAction: .sessionVerification) }) + case .secureBackup: + ListRow(label: .default(title: L10n.commonChatBackup, + icon: \.key), + details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil, + kind: .navigationLink { context.send(viewAction: .secureBackup) }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup) + default: + EmptyView() } } } @@ -210,8 +212,8 @@ struct SettingsScreen: View { } @ViewBuilder - private var secureBackupBadge: some View { - if context.viewState.showSecureBackupBadge { + private var securitySectionBadge: some View { + if context.viewState.showSecuritySectionBadge { BadgeView(size: 10) } } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index c6e6c9702..d4edfafde 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -166,6 +166,16 @@ class ClientProxy: ClientProxyProtocol { let digest = SHA256.hash(data: data) return digest.compactMap { String(format: "%02x", $0) }.joined() }() + + func isOnlyDeviceLeft() async -> Result { + do { + let result = try await client.encryption().isLastDevice() + return .success(result) + } catch { + MXLog.error("Failed checking isLastDevice with error: \(error)") + return .failure(.failedCheckingIsLastDevice(error)) + } + } func startSync() { guard !hasEncounteredAuthError else { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 2362fd134..76368c719 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -49,6 +49,7 @@ enum ClientProxyError: Error { case failedSearchingUsers case failedGettingUserProfile case failedSettingUserAvatar + case failedCheckingIsLastDevice(Error?) } enum SlidingSyncConstants { @@ -96,6 +97,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { var secureBackupController: SecureBackupControllerProtocol { get } + func isOnlyDeviceLeft() async -> Result + func startSync() func stopSync() diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index b2523a2c6..23666819f 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -42,9 +42,8 @@ class MockClientProxy: ClientProxyProtocol { lazy var secureBackupController: SecureBackupControllerProtocol = { let secureBackupController = SecureBackupControllerMock() - secureBackupController.underlyingRecoveryKeyState = .init(CurrentValueSubject(.enabled)) + secureBackupController.underlyingRecoveryState = .init(CurrentValueSubject(.enabled)) secureBackupController.underlyingKeyBackupState = .init(CurrentValueSubject(.enabled)) - secureBackupController.isLastSessionReturnValue = .success(false) return secureBackupController }() @@ -54,6 +53,10 @@ class MockClientProxy: ClientProxyProtocol { self.roomSummaryProvider = roomSummaryProvider } + func isOnlyDeviceLeft() async -> Result { + .success(false) + } + func startSync() { } func stopSync() { } diff --git a/ElementX/Sources/Services/SecureBackup/SecureBackupController.swift b/ElementX/Sources/Services/SecureBackup/SecureBackupController.swift index 1bb750738..fad1033a1 100644 --- a/ElementX/Sources/Services/SecureBackup/SecureBackupController.swift +++ b/ElementX/Sources/Services/SecureBackup/SecureBackupController.swift @@ -21,7 +21,7 @@ import MatrixRustSDK class SecureBackupController: SecureBackupControllerProtocol { private let encryption: Encryption - private let recoveryKeyStateSubject = CurrentValueSubject(.unknown) + private let recoveryStateSubject = CurrentValueSubject(.unknown) private let keyBackupStateSubject = CurrentValueSubject(.unknown) // periphery:ignore - retaining purpose @@ -33,8 +33,8 @@ class SecureBackupController: SecureBackupControllerProtocol { /// Used to dedupe remote backup state requests @CancellableTask private var remoteBackupStateTask: Task? - var recoveryKeyState: CurrentValuePublisher { - recoveryKeyStateSubject.asCurrentValuePublisher() + var recoveryState: CurrentValuePublisher { + recoveryStateSubject.asCurrentValuePublisher() } var keyBackupState: CurrentValuePublisher { @@ -76,16 +76,16 @@ class SecureBackupController: SecureBackupControllerProtocol { switch state { case .unknown: - recoveryKeyStateSubject.send(.unknown) + recoveryStateSubject.send(.unknown) case .enabled: - recoveryKeyStateSubject.send(.enabled) + recoveryStateSubject.send(.enabled) case .disabled: - recoveryKeyStateSubject.send(.disabled) + recoveryStateSubject.send(.disabled) case .incomplete: - recoveryKeyStateSubject.send(.incomplete) + recoveryStateSubject.send(.incomplete) } - MXLog.info("Recovery state changed to: \(state), setting local state to \(recoveryKeyStateSubject.value)") + MXLog.info("Recovery state changed to: \(state), setting local state to \(recoveryStateSubject.value)") }) updateBackupStateFromRemote() @@ -120,7 +120,7 @@ class SecureBackupController: SecureBackupControllerProtocol { func generateRecoveryKey() async -> Result { do { - guard recoveryKeyState.value == .disabled else { + guard recoveryState.value == .disabled else { MXLog.info("Resetting recovery key") let key = try await encryption.resetRecoveryKey() @@ -135,9 +135,9 @@ class SecureBackupController: SecureBackupControllerProtocol { switch state { case .starting, .creatingBackup, .creatingRecoveryKey, .backingUp: - recoveryKeyStateSubject.send(.settingUp) + recoveryStateSubject.send(.settingUp) case .done: - recoveryKeyStateSubject.send(.enabled) + recoveryStateSubject.send(.enabled) case .roomKeyUploadError: MXLog.error("Failed enabling recovery: room key upload error") keyUploadErrored = true @@ -162,17 +162,7 @@ class SecureBackupController: SecureBackupControllerProtocol { return .failure(.failedConfirmingRecoveryKey) } } - - func isLastSession() async -> Result { - do { - MXLog.info("Checking if last session") - return try await .success(encryption.isLastDevice()) - } catch { - MXLog.error("Failed checking if last session with error: \(error)") - return .failure(.failedFetchingSessionState) - } - } - + func waitForKeyBackupUpload() async -> Result { do { MXLog.info("Waiting for backup upload steady state") diff --git a/ElementX/Sources/Services/SecureBackup/SecureBackupControllerProtocol.swift b/ElementX/Sources/Services/SecureBackup/SecureBackupControllerProtocol.swift index 08c2ae5f1..95f6038ff 100644 --- a/ElementX/Sources/Services/SecureBackup/SecureBackupControllerProtocol.swift +++ b/ElementX/Sources/Services/SecureBackup/SecureBackupControllerProtocol.swift @@ -17,7 +17,7 @@ import Combine import Foundation -enum SecureBackupRecoveryKeyState { +enum SecureBackupRecoveryState { case unknown case disabled case enabled @@ -50,7 +50,7 @@ enum SecureBackupControllerError: Error { // sourcery: AutoMockable protocol SecureBackupControllerProtocol { - var recoveryKeyState: CurrentValuePublisher { get } + var recoveryState: CurrentValuePublisher { get } var keyBackupState: CurrentValuePublisher { get } @@ -60,7 +60,5 @@ protocol SecureBackupControllerProtocol { func generateRecoveryKey() async -> Result func confirmRecoveryKey(_ key: String) async -> Result - func isLastSession() async -> Result - func waitForKeyBackupUpload() async -> Result } diff --git a/ElementX/Sources/Services/Session/MockUserSession.swift b/ElementX/Sources/Services/Session/MockUserSession.swift index 937422a8e..0ab4ee9a7 100644 --- a/ElementX/Sources/Services/Session/MockUserSession.swift +++ b/ElementX/Sources/Services/Session/MockUserSession.swift @@ -25,5 +25,5 @@ struct MockUserSession: UserSessionProtocol { let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol - var sessionVerificationState: CurrentValuePublisher = .init(.init(true)) + var sessionSecurityStatePublisher: AnyPublisher = CurrentValueSubject(.init(verificationState: .verified, recoveryState: .enabled)).eraseToAnyPublisher() } diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index 7308382e6..b413fb689 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -18,10 +18,12 @@ import Combine import Foundation class UserSession: UserSessionProtocol { + private let sessionVerificationStateSubject: CurrentValueSubject = .init(.unknown) + private var cancellables = Set() private var checkSessionVerificationStateCancellable: AnyCancellable? - private var checkSessionVerificationStateTask: Task? + private var retrieveSessionVerificationControllerTask: Task? private var authErrorCancellable: AnyCancellable? @@ -40,7 +42,7 @@ class UserSession: UserSessionProtocol { sessionVerificationController?.callbacks.sink { [weak self] callback in switch callback { case .finished: - self?.sessionVerificationStateSubject.send(true) + self?.sessionVerificationStateSubject.send(.verified) default: break } @@ -49,74 +51,31 @@ class UserSession: UserSessionProtocol { } } - private var sessionVerificationStateSubject: CurrentValueSubject = .init(nil) - var sessionVerificationState: CurrentValuePublisher { - sessionVerificationStateSubject.asCurrentValuePublisher() - } + let sessionSecurityStatePublisher: AnyPublisher init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) { self.clientProxy = clientProxy self.mediaProvider = mediaProvider self.voiceMessageMediaManager = voiceMessageMediaManager - setupSessionVerificationWatchdog() - setupAuthErrorWatchdog() - } - - // MARK: - Private - - private func setupSessionVerificationWatchdog() { - checkSessionVerificationStateCancellable = clientProxy.callbacks + sessionSecurityStatePublisher = Publishers.CombineLatest(sessionVerificationStateSubject, clientProxy.secureBackupController.recoveryState) + .map { + MXLog.info("Session security state changed, verificationState: \($0), recoveryState: \($1)") + return SessionSecurityState(verificationState: $0, recoveryState: $1) + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + + clientProxy.callbacks .receive(on: DispatchQueue.main) .sink { [weak self] callback in if callback.isSyncUpdate { - self?.attemptSessionVerification() + self?.checkSessionVerificationState() } } - } - - private func attemptSessionVerification() { - guard checkSessionVerificationStateTask == nil else { - MXLog.info("Session verification state check already in progress") - return - } + .store(in: &cancellables) - MXLog.info("Checking session verification state") - - checkSessionVerificationStateTask = Task { - MXLog.info("Retrieving session verification controller") - - switch await clientProxy.sessionVerificationControllerProxy() { - case .success(let sessionVerificationController): - MXLog.info("Retrieving session verification state") - - guard case let .success(isVerified) = await sessionVerificationController.isVerified() else { - MXLog.error("Failed checking verification state. Will retry on the next sync update.") - return - } - - tearDownSessionVerificationControllerWatchdog() - - self.sessionVerificationController = sessionVerificationController - - MXLog.info("Session verified: \(isVerified)") - - sessionVerificationStateSubject.send(isVerified) - - checkSessionVerificationStateTask = nil - case .failure(let error): - MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.") - } - } - } - - private func tearDownSessionVerificationControllerWatchdog() { - checkSessionVerificationStateCancellable = nil - } - - // MARK: Auth Error Watchdog - - private func setupAuthErrorWatchdog() { authErrorCancellable = clientProxy.callbacks .receive(on: DispatchQueue.main) .sink { [weak self] callback in @@ -124,14 +83,68 @@ class UserSession: UserSessionProtocol { switch callback { case .receivedAuthError(let isSoftLogout): callbacks.send(.didReceiveAuthError(isSoftLogout: isSoftLogout)) - tearDownAuthErrorWatchdog() + authErrorCancellable = nil default: break } } } - - private func tearDownAuthErrorWatchdog() { - authErrorCancellable = nil + + // MARK: - Private + + private func checkSessionVerificationState() { + guard retrieveSessionVerificationControllerTask == nil else { + MXLog.info("Session verification state check already in progress") + return + } + + guard sessionVerificationController == nil else { + Task { + await updateSessionVerificationState() + } + return + } + + MXLog.info("Retrieving session verification controller") + + retrieveSessionVerificationControllerTask = Task { + switch await clientProxy.sessionVerificationControllerProxy() { + case .success(let sessionVerificationController): + self.sessionVerificationController = sessionVerificationController + await updateSessionVerificationState() + + retrieveSessionVerificationControllerTask = nil + case .failure(let error): + MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.") + } + } + } + + private func updateSessionVerificationState() async { + guard let sessionVerificationController else { + fatalError("This point should never be reached") + } + + MXLog.info("Checking session verification state") + + guard case let .success(isVerified) = await sessionVerificationController.isVerified() else { + MXLog.error("Failed checking verification state. Will retry on the next sync update.") + return + } + + if isVerified { + sessionVerificationStateSubject.send(.verified) + } else { + guard case let .success(isLastDevice) = await clientProxy.isOnlyDeviceLeft() else { + MXLog.error("Failed checking isLastDevice. Will retry on the next sync update.") + return + } + + if isLastDevice { + sessionVerificationStateSubject.send(.unverifiedLastSession) + } else { + sessionVerificationStateSubject.send(.unverified) + } + } } } diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index 9502fb11c..b9939d0a9 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -21,6 +21,18 @@ enum UserSessionCallback { case didReceiveAuthError(isSoftLogout: Bool) } +enum SessionVerificationState { + case unknown + case verified + case unverified + case unverifiedLastSession +} + +struct SessionSecurityState: Equatable { + let verificationState: SessionVerificationState + let recoveryState: SecureBackupRecoveryState +} + protocol UserSessionProtocol { var homeserver: String { get } var userID: String { get } @@ -30,7 +42,7 @@ protocol UserSessionProtocol { var mediaProvider: MediaProviderProtocol { get } var voiceMessageMediaManager: VoiceMessageMediaManagerProtocol { get } - var sessionVerificationState: CurrentValuePublisher { get } + var sessionSecurityStatePublisher: AnyPublisher { get } var sessionVerificationController: SessionVerificationControllerProxyProtocol? { get } var callbacks: PassthroughSubject { get } diff --git a/UnitTests/Sources/UserSession/UserSessionTests.swift b/UnitTests/Sources/UserSession/UserSessionTests.swift index 761e15923..bf1d6a1e5 100644 --- a/UnitTests/Sources/UserSession/UserSessionTests.swift +++ b/UnitTests/Sources/UserSession/UserSessionTests.swift @@ -32,8 +32,8 @@ final class UserSessionTests: XCTestCase { func test_whenUserSessionReceivesSyncUpdateAndSessionControllerRetrievedAndSessionNotVerified_sessionVerificationNeededEventReceived() throws { let expectation = expectation(description: "SessionVerificationNeeded expectation") - userSession.sessionVerificationState.sink { isVerified in - if let isVerified, isVerified == false { + userSession.sessionSecurityStatePublisher.sink { securityState in + if securityState.verificationState == .unverified { expectation.fulfill() } }