mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Fix a bug where the security banner has the wrong state when out of sync. (#3511)
This commit is contained in:
parent
c91a9bd9c9
commit
bef1eda533
@ -66,10 +66,28 @@ enum HomeScreenRoomListMode: CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HomeScreenBannerMode {
|
enum HomeScreenSecurityBannerMode: Equatable {
|
||||||
case none
|
case none
|
||||||
case dismissed
|
case dismissed
|
||||||
case show
|
case show(HomeScreenRecoveryKeyConfirmationBanner.State)
|
||||||
|
|
||||||
|
var isDismissed: Bool {
|
||||||
|
switch self {
|
||||||
|
case .dismissed: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isShown: Bool {
|
||||||
|
switch self {
|
||||||
|
case .show: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HomeScreenMigrationBannerMode {
|
||||||
|
case none, show, dismissed
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HomeScreenViewState: BindableState {
|
struct HomeScreenViewState: BindableState {
|
||||||
@ -77,8 +95,8 @@ struct HomeScreenViewState: BindableState {
|
|||||||
var userDisplayName: String?
|
var userDisplayName: String?
|
||||||
var userAvatarURL: URL?
|
var userAvatarURL: URL?
|
||||||
|
|
||||||
var securityBannerMode = HomeScreenBannerMode.none
|
var securityBannerMode = HomeScreenSecurityBannerMode.none
|
||||||
var slidingSyncMigrationBannerMode = HomeScreenBannerMode.none
|
var slidingSyncMigrationBannerMode = HomeScreenMigrationBannerMode.none
|
||||||
|
|
||||||
var requiresExtraAccountSetup = false
|
var requiresExtraAccountSetup = false
|
||||||
|
|
||||||
|
@ -55,16 +55,15 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
|||||||
.sink { [weak self] securityState in
|
.sink { [weak self] securityState in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
switch (securityState.verificationState, securityState.recoveryState) {
|
switch securityState.recoveryState {
|
||||||
case (.verified, .disabled):
|
case .disabled:
|
||||||
state.requiresExtraAccountSetup = true
|
state.requiresExtraAccountSetup = true
|
||||||
state.securityBannerMode = .show
|
if !state.securityBannerMode.isDismissed {
|
||||||
case (.verified, .incomplete):
|
state.securityBannerMode = .show(.setUpRecovery)
|
||||||
state.requiresExtraAccountSetup = true
|
|
||||||
|
|
||||||
if state.securityBannerMode != .dismissed {
|
|
||||||
state.securityBannerMode = .show
|
|
||||||
}
|
}
|
||||||
|
case .incomplete:
|
||||||
|
state.requiresExtraAccountSetup = true
|
||||||
|
state.securityBannerMode = .show(.recoveryOutOfSync)
|
||||||
default:
|
default:
|
||||||
state.securityBannerMode = .none
|
state.securityBannerMode = .none
|
||||||
state.requiresExtraAccountSetup = false
|
state.requiresExtraAccountSetup = false
|
||||||
|
@ -120,7 +120,7 @@ struct HomeScreenContent: View {
|
|||||||
private var topSection: some View {
|
private var topSection: some View {
|
||||||
// An empty VStack causes glitches within the room list
|
// An empty VStack causes glitches within the room list
|
||||||
if context.viewState.shouldShowFilters ||
|
if context.viewState.shouldShowFilters ||
|
||||||
context.viewState.securityBannerMode == .show ||
|
context.viewState.securityBannerMode.isShown ||
|
||||||
context.viewState.slidingSyncMigrationBannerMode == .show {
|
context.viewState.slidingSyncMigrationBannerMode == .show {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if context.viewState.shouldShowFilters {
|
if context.viewState.shouldShowFilters {
|
||||||
@ -129,8 +129,8 @@ struct HomeScreenContent: View {
|
|||||||
|
|
||||||
if context.viewState.slidingSyncMigrationBannerMode == .show {
|
if context.viewState.slidingSyncMigrationBannerMode == .show {
|
||||||
HomeScreenSlidingSyncMigrationBanner(context: context)
|
HomeScreenSlidingSyncMigrationBanner(context: context)
|
||||||
} else if context.viewState.securityBannerMode == .show {
|
} else if case let .show(state) = context.viewState.securityBannerMode {
|
||||||
HomeScreenRecoveryKeyConfirmationBanner(requiresExtraAccountSetup: context.viewState.requiresExtraAccountSetup, context: context)
|
HomeScreenRecoveryKeyConfirmationBanner(state: state, context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.compound.bgCanvasDefault)
|
.background(Color.compound.bgCanvasDefault)
|
||||||
|
@ -10,13 +10,37 @@ import Compound
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct HomeScreenRecoveryKeyConfirmationBanner: View {
|
struct HomeScreenRecoveryKeyConfirmationBanner: View {
|
||||||
let requiresExtraAccountSetup: Bool
|
enum State { case setUpRecovery, recoveryOutOfSync }
|
||||||
|
let state: State
|
||||||
var context: HomeScreenViewModel.Context
|
var context: HomeScreenViewModel.Context
|
||||||
|
|
||||||
var title: String { requiresExtraAccountSetup ? L10n.bannerSetUpRecoveryTitle : L10n.confirmRecoveryKeyBannerTitle }
|
var title: String {
|
||||||
var message: String { requiresExtraAccountSetup ? L10n.bannerSetUpRecoveryContent : L10n.confirmRecoveryKeyBannerMessage }
|
switch state {
|
||||||
var actionTitle: String { requiresExtraAccountSetup ? L10n.bannerSetUpRecoverySubmit : L10n.confirmRecoveryKeyBannerPrimaryButtonTitle }
|
case .setUpRecovery: L10n.bannerSetUpRecoveryTitle
|
||||||
var primaryAction: HomeScreenViewAction { requiresExtraAccountSetup ? .setupRecovery : .confirmRecoveryKey }
|
case .recoveryOutOfSync: L10n.confirmRecoveryKeyBannerTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch state {
|
||||||
|
case .setUpRecovery: L10n.bannerSetUpRecoveryContent
|
||||||
|
case .recoveryOutOfSync: L10n.confirmRecoveryKeyBannerMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionTitle: String {
|
||||||
|
switch state {
|
||||||
|
case .setUpRecovery: L10n.bannerSetUpRecoverySubmit
|
||||||
|
case .recoveryOutOfSync: L10n.confirmRecoveryKeyBannerPrimaryButtonTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryAction: HomeScreenViewAction {
|
||||||
|
switch state {
|
||||||
|
case .setUpRecovery: .setupRecovery
|
||||||
|
case .recoveryOutOfSync: .confirmRecoveryKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@ -37,7 +61,7 @@ struct HomeScreenRecoveryKeyConfirmationBanner: View {
|
|||||||
.foregroundColor(.compound.textPrimary)
|
.foregroundColor(.compound.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
if requiresExtraAccountSetup {
|
if state == .setUpRecovery {
|
||||||
Button {
|
Button {
|
||||||
context.send(viewAction: .skipRecoveryKeyConfirmation)
|
context.send(viewAction: .skipRecoveryKeyConfirmation)
|
||||||
} label: {
|
} label: {
|
||||||
@ -63,7 +87,7 @@ struct HomeScreenRecoveryKeyConfirmationBanner: View {
|
|||||||
.buttonStyle(.compound(.primary, size: .medium))
|
.buttonStyle(.compound(.primary, size: .medium))
|
||||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.recoveryKeyConfirmationBannerContinue)
|
.accessibilityIdentifier(A11yIdentifiers.homeScreen.recoveryKeyConfirmationBannerContinue)
|
||||||
|
|
||||||
if !requiresExtraAccountSetup {
|
if state == .recoveryOutOfSync {
|
||||||
Button {
|
Button {
|
||||||
context.send(viewAction: .resetEncryption)
|
context.send(viewAction: .resetEncryption)
|
||||||
} label: {
|
} label: {
|
||||||
@ -81,10 +105,10 @@ struct HomeScreenRecoveryKeyConfirmationBanner_Previews: PreviewProvider, Testab
|
|||||||
static let viewModel = buildViewModel()
|
static let viewModel = buildViewModel()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
HomeScreenRecoveryKeyConfirmationBanner(requiresExtraAccountSetup: true,
|
HomeScreenRecoveryKeyConfirmationBanner(state: .setUpRecovery,
|
||||||
context: viewModel.context)
|
context: viewModel.context)
|
||||||
.previewDisplayName("Set up recovery")
|
.previewDisplayName("Set up recovery")
|
||||||
HomeScreenRecoveryKeyConfirmationBanner(requiresExtraAccountSetup: false,
|
HomeScreenRecoveryKeyConfirmationBanner(state: .recoveryOutOfSync,
|
||||||
context: viewModel.context)
|
context: viewModel.context)
|
||||||
.previewDisplayName("Out of sync")
|
.previewDisplayName("Out of sync")
|
||||||
}
|
}
|
||||||
|
@ -20,13 +20,6 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
cancellables.removeAll()
|
cancellables.removeAll()
|
||||||
roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
|
|
||||||
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", roomSummaryProvider: roomSummaryProvider))
|
|
||||||
viewModel = HomeScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
||||||
analyticsService: ServiceLocator.shared.analytics,
|
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
|
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
@ -34,6 +27,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSelectRoom() async throws {
|
func testSelectRoom() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
let mockRoomId = "mock_room_id"
|
let mockRoomId = "mock_room_id"
|
||||||
var correctResult = false
|
var correctResult = false
|
||||||
var selectedRoomId = ""
|
var selectedRoomId = ""
|
||||||
@ -57,6 +52,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testTapUserAvatar() async throws {
|
func testTapUserAvatar() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
var correctResult = false
|
var correctResult = false
|
||||||
|
|
||||||
viewModel.actions
|
viewModel.actions
|
||||||
@ -76,6 +73,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testLeaveRoomAlert() async throws {
|
func testLeaveRoomAlert() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
let mockRoomId = "1"
|
let mockRoomId = "1"
|
||||||
|
|
||||||
clientProxy.roomForIdentifierClosure = { _ in .joined(JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room"))) }
|
clientProxy.roomForIdentifierClosure = { _ in .joined(JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room"))) }
|
||||||
@ -92,6 +91,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testLeaveRoomError() async throws {
|
func testLeaveRoomError() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
let mockRoomId = "1"
|
let mockRoomId = "1"
|
||||||
let room = JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room"))
|
let room = JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room"))
|
||||||
room.leaveRoomClosure = { .failure(.sdkError(ClientProxyMockError.generic)) }
|
room.leaveRoomClosure = { .failure(.sdkError(ClientProxyMockError.generic)) }
|
||||||
@ -110,6 +111,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testLeaveRoomSuccess() async throws {
|
func testLeaveRoomSuccess() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
let mockRoomId = "1"
|
let mockRoomId = "1"
|
||||||
var correctResult = false
|
var correctResult = false
|
||||||
let expectation = expectation(description: #function)
|
let expectation = expectation(description: #function)
|
||||||
@ -136,6 +139,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testShowRoomDetails() async throws {
|
func testShowRoomDetails() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
let mockRoomId = "1"
|
let mockRoomId = "1"
|
||||||
var correctResult = false
|
var correctResult = false
|
||||||
viewModel.actions
|
viewModel.actions
|
||||||
@ -155,6 +160,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testFilters() async throws {
|
func testFilters() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
context.filtersState.activateFilter(.people)
|
context.filtersState.activateFilter(.people)
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
XCTAssertEqual(roomSummaryProvider.roomListPublisher.value.count, 2)
|
XCTAssertEqual(roomSummaryProvider.roomListPublisher.value.count, 2)
|
||||||
@ -162,6 +169,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSearch() async throws {
|
func testSearch() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
context.isSearchFieldFocused = true
|
context.isSearchFieldFocused = true
|
||||||
context.searchQuery = "lude to Found"
|
context.searchQuery = "lude to Found"
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
@ -170,6 +179,8 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testFiltersEmptyState() async throws {
|
func testFiltersEmptyState() async throws {
|
||||||
|
setupViewModel()
|
||||||
|
|
||||||
context.filtersState.activateFilter(.people)
|
context.filtersState.activateFilter(.people)
|
||||||
context.filtersState.activateFilter(.favourites)
|
context.filtersState.activateFilter(.favourites)
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
@ -177,4 +188,89 @@ class HomeScreenViewModelTests: XCTestCase {
|
|||||||
context.isSearchFieldFocused = true
|
context.isSearchFieldFocused = true
|
||||||
XCTAssertFalse(context.viewState.shouldShowEmptyFilterState)
|
XCTAssertFalse(context.viewState.shouldShowEmptyFilterState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSetUpRecoveryBannerState() async throws {
|
||||||
|
// Given a view model without a visible security banner.
|
||||||
|
let securityStateStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .verified, recoveryState: .unknown))
|
||||||
|
setupViewModel(securityStatePublisher: securityStateStateSubject.asCurrentValuePublisher())
|
||||||
|
XCTAssertEqual(context.viewState.securityBannerMode, .none)
|
||||||
|
|
||||||
|
// When the recovery state comes through as disabled.
|
||||||
|
var deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == true }
|
||||||
|
securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .disabled))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the banner should be shown to set up recovery.
|
||||||
|
XCTAssertEqual(context.viewState.securityBannerMode, .show(.setUpRecovery))
|
||||||
|
|
||||||
|
// When the recovery is enabled.
|
||||||
|
deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == false }
|
||||||
|
securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .enabled))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the banner should no longer be shown.
|
||||||
|
XCTAssertEqual(context.viewState.securityBannerMode, .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDismissSetUpRecoveryBannerState() async throws {
|
||||||
|
// Given a view model with the setup recovery banner shown.
|
||||||
|
let securityStateStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .verified, recoveryState: .unknown))
|
||||||
|
setupViewModel(securityStatePublisher: securityStateStateSubject.asCurrentValuePublisher())
|
||||||
|
var deferred = deferFulfillment(context.$viewState) { $0.securityBannerMode == .show(.setUpRecovery) }
|
||||||
|
securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .disabled))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// When the banner is dismissed.
|
||||||
|
deferred = deferFulfillment(context.$viewState) { $0.securityBannerMode == .dismissed }
|
||||||
|
context.send(viewAction: .skipRecoveryKeyConfirmation)
|
||||||
|
|
||||||
|
// Then the banner should no longer be shown.
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// And when the recovery state comes through a second time the banner should still not be shown.
|
||||||
|
let failure = deferFailure(context.$viewState, timeout: 1) { $0.securityBannerMode != .dismissed }
|
||||||
|
securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .disabled))
|
||||||
|
try await failure.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutOfSyncRecoveryBannerState() async throws {
|
||||||
|
// Given a view model without a visible security banner.
|
||||||
|
let securityStateStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .verified, recoveryState: .unknown))
|
||||||
|
setupViewModel(securityStatePublisher: securityStateStateSubject.asCurrentValuePublisher())
|
||||||
|
XCTAssertEqual(context.viewState.securityBannerMode, .none)
|
||||||
|
|
||||||
|
// When the recovery state comes through as incomplete.
|
||||||
|
var deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == true }
|
||||||
|
securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .incomplete))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the banner should be shown for out of sync recovery.
|
||||||
|
XCTAssertEqual(context.viewState.securityBannerMode, .show(.recoveryOutOfSync))
|
||||||
|
|
||||||
|
// When the recovery is enabled.
|
||||||
|
deferred = deferFulfillment(context.$viewState) { $0.requiresExtraAccountSetup == false }
|
||||||
|
securityStateStateSubject.send(.init(verificationState: .verified, recoveryState: .enabled))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the banner should no longer be shown.
|
||||||
|
XCTAssertEqual(context.viewState.securityBannerMode, .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func setupViewModel(securityStatePublisher: CurrentValuePublisher<SessionSecurityState, Never>? = nil) {
|
||||||
|
roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
|
||||||
|
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com",
|
||||||
|
roomSummaryProvider: roomSummaryProvider))
|
||||||
|
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
|
||||||
|
if let securityStatePublisher {
|
||||||
|
userSession.sessionSecurityStatePublisher = securityStatePublisher
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel = HomeScreenViewModel(userSession: userSession,
|
||||||
|
analyticsService: ServiceLocator.shared.analytics,
|
||||||
|
appSettings: ServiceLocator.shared.settings,
|
||||||
|
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
|
||||||
|
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user