Ignore repeated confirms on the server selection screen

Tapping Continue and then the keyboard's Done key (or double tapping
Continue) started two server configurations. With a password-only server
both asked the flow to show the login screen, and the second request hit
the authentication state machine's unexpected transition fatalError.

Keep the running confirmation task and ignore further confirms until it
finishes, so the flow continues once. The guard clears afterwards so
confirming again after going back from the login screen still works.

Fixes ELEMENT-X-IOS-XED
This commit is contained in:
Mauro Romito
2026-10-15 11:28:04 +02:00
committed by Mauro
parent 11651bcd23
commit 09ecec4306
2 changed files with 41 additions and 5 deletions
@@ -20,6 +20,8 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
/// Debounces autocompletion from user input /// Debounces autocompletion from user input
private var debouncedAutocompleteTask: Task<Void, Never>? private var debouncedAutocompleteTask: Task<Void, Never>?
/// The confirmation in progress, used to ignore repeated confirms (e.g. Continue then keyboard Done).
private var confirmTask: Task<Void, Never>?
private var actionsSubject: PassthroughSubject<ServerSelectionScreenViewModelAction, Never> = .init() private var actionsSubject: PassthroughSubject<ServerSelectionScreenViewModelAction, Never> = .init()
@@ -65,11 +67,15 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
guard state.textField != textField else { return } // stop the infinite loop guard state.textField != textField else { return } // stop the infinite loop
state.textField = textField state.textField = textField
case .confirm: case .confirm:
switch state.mode { guard confirmTask == nil else { return }
case .userInput: confirmTask = Task {
Task { await configureHomeserver() } switch state.mode {
case .picker: case .userInput:
Task { await pickServer() } await configureHomeserver()
case .picker:
await pickServer()
}
confirmTask = nil
} }
case .clearFooterError: case .clearFooterError:
clearFooterError() clearFooterError()
@@ -210,6 +210,36 @@ struct ServerSelectionScreenViewModelTests {
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2) #expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
} }
@Test
mutating func userInputConfirmTwiceContinuesOnce() async throws {
// Given a view model for login with a server that doesn't support OAuth.
try setup(authenticationFlow: .login)
context.serverNameOrBaseURL = Self.passwordOnlyServer
// When confirming a second time before the first confirmation has finished (e.g. Continue then keyboard Done).
var continueCount = 0
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The flow should only continue once.") { action in
guard action.isContinueWithPassword else { return false }
continueCount += 1
return continueCount > 1
}
context.send(viewAction: .confirm)
context.send(viewAction: .confirm)
try await deferredFailure.fulfill()
// Then the server should only be configured once.
#expect(continueCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
// And when confirming again once finished (e.g. after navigating back from the login screen).
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the flow should continue again.
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
}
// MARK: - Picker mode // MARK: - Picker mode
@Test @Test