Configure the AuthenticationService later now that we have 2 flows on the start screen. (#3316)

* Don't query the homeserver until confirming it (or selecting a different one).

* Setup the infrastructure to test AuthenticationService.

Implement basic tests for configuration & password login.

* Use the real AuthenticationService with a mock Client in all of the tests.

* Add tests for the ServerConfirmationScreenViewModel.

* Remove redundant view state and test for it.
This commit is contained in:
Doug
2024-09-25 14:40:18 +01:00
committed by GitHub
parent af8c16150b
commit a8dbda90d9
44 changed files with 1209 additions and 298 deletions
@@ -485,7 +485,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
encryptionKeyProvider: EncryptionKeyProvider(),
appSettings: appSettings,
appHooks: appHooks)
_ = await authenticationService.configure(for: userSession.clientProxy.homeserver)
_ = await authenticationService.configure(for: userSession.clientProxy.homeserver, flow: .login)
let parameters = SoftLogoutScreenCoordinatorParameters(authenticationService: authenticationService,
credentials: credentials,
@@ -86,11 +86,11 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .loginManually:
Task { await self.startAuthentication(flow: .login) }
showServerConfirmationScreen(authenticationFlow: .login)
case .loginWithQR:
startQRCodeLogin()
case .register:
Task { await self.startAuthentication(flow: .register) }
showServerConfirmationScreen(authenticationFlow: .register)
case .reportProblem:
showReportProblemScreen()
}
@@ -113,7 +113,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil)
Task { await self.startAuthentication(flow: .login) }
showServerConfirmationScreen(authenticationFlow: .login)
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
case .done(let userSession):
@@ -137,25 +137,14 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
bugReportFlowCoordinator?.start()
}
private func startAuthentication(flow: AuthenticationFlow) async {
startLoading()
switch await authenticationService.configure(for: appSettings.defaultHomeserverAddress) {
case .success:
stopLoading()
showServerConfirmationScreen(authenticationFlow: flow)
case .failure:
stopLoading()
showServerSelectionScreen(authenticationFlow: flow, isModallyPresented: false)
}
}
private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow, isModallyPresented: Bool) {
// TODO: Move this method after showServerConfirmationScreen
private func showServerSelectionScreen(authenticationFlow: AuthenticationFlow) {
let navigationCoordinator = NavigationStackCoordinator()
let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService,
userIndicatorController: userIndicatorController,
isModallyPresented: isModallyPresented)
authenticationFlow: authenticationFlow,
slidingSyncLearnMoreURL: appSettings.slidingSyncLearnMoreURL,
userIndicatorController: userIndicatorController)
let coordinator = ServerSelectionScreenCoordinator(parameters: parameters)
coordinator.actions
@@ -164,42 +153,26 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .updated:
if isModallyPresented {
navigationStackCoordinator.setSheetCoordinator(nil)
} else {
// We are here because the default server failed to respond.
if authenticationService.homeserver.value.loginMode == .password {
if authenticationFlow == .login {
// Add the password login screen directly to the flow, its fine.
showLoginScreen()
} else {
// Add the web registration screen directly to the flow, its fine.
showWebRegistration()
}
} else {
// OIDC is presented from the confirmation screen so replace the
// server selection screen which was inserted to handle the failure.
navigationStackCoordinator.pop()
showServerConfirmationScreen(authenticationFlow: authenticationFlow)
}
}
navigationStackCoordinator.setSheetCoordinator(nil)
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
if isModallyPresented {
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(navigationCoordinator)
} else {
navigationStackCoordinator.push(coordinator)
}
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(navigationCoordinator)
}
private func showServerConfirmationScreen(authenticationFlow: AuthenticationFlow) {
// Reset the service back to the default homeserver before continuing. This ensures
// we check that registration is supported if it was previously configured for login.
authenticationService.reset()
let parameters = ServerConfirmationScreenCoordinatorParameters(authenticationService: authenticationService,
authenticationFlow: authenticationFlow)
authenticationFlow: authenticationFlow,
slidingSyncLearnMoreURL: appSettings.slidingSyncLearnMoreURL,
userIndicatorController: userIndicatorController)
let coordinator = ServerConfirmationScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
@@ -215,7 +188,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
showLoginScreen()
}
case .changeServer:
showServerSelectionScreen(authenticationFlow: authenticationFlow, isModallyPresented: true)
showServerSelectionScreen(authenticationFlow: authenticationFlow)
}
}
.store(in: &cancellables)
@@ -0,0 +1,22 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import MatrixRustSDK
extension AuthenticationClientBuilderFactoryMock {
struct Configuration {
var builderConfiguration: AuthenticationClientBuilderMock.Configuration = .init()
}
convenience init(configuration: Configuration) {
self.init()
let clientBuilder = AuthenticationClientBuilderMock(configuration: configuration.builderConfiguration)
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReturnValue = clientBuilder
}
}
@@ -0,0 +1,47 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import MatrixRustSDK
extension AuthenticationClientBuilderMock {
struct Configuration {
var homeserverClients = [
"matrix.org": ClientSDKMock(configuration: .init()),
"example.com": ClientSDKMock(configuration: .init(serverAddress: "example.com",
homeserverURL: "https://matrix.example.com",
slidingSyncVersion: .native,
supportsPasswordLogin: true,
elementWellKnown: "")),
"company.com": ClientSDKMock(configuration: .init(serverAddress: "company.com",
homeserverURL: "https://matrix.company.com",
slidingSyncVersion: .native,
oidcLoginURL: "https://auth.company.com/oidc",
supportsPasswordLogin: false,
elementWellKnown: "")),
"server.net": ClientSDKMock(configuration: .init(serverAddress: "server.net",
homeserverURL: "https://matrix.example.com",
slidingSyncVersion: .native,
supportsPasswordLogin: false,
elementWellKnown: ""))
]
var qrCodeClient = ClientSDKMock(configuration: .init())
}
convenience init(configuration: Configuration) {
self.init()
buildHomeserverAddressClosure = { address in
guard let client = configuration.homeserverClients[address] else {
throw ClientBuildError.ServerUnreachable(message: "Not a known homeserver.")
}
return client
}
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReturnValue = configuration.qrCodeClient
}
}
@@ -1819,6 +1819,230 @@ class AudioSessionMock: AudioSessionProtocol {
try setActiveOptionsClosure?(active, options)
}
}
class AuthenticationClientBuilderFactoryMock: AuthenticationClientBuilderFactoryProtocol {
//MARK: - makeBuilder
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = 0
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount: Int {
get {
if Thread.isMainThread {
return makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = newValue
}
}
}
}
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCalled: Bool {
return makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount > 0
}
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments: (sessionDirectories: SessionDirectories, passphrase: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks)?
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedInvocations: [(sessionDirectories: SessionDirectories, passphrase: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks)] = []
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue: AuthenticationClientBuilderProtocol!
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReturnValue: AuthenticationClientBuilderProtocol! {
get {
if Thread.isMainThread {
return makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue
} else {
var returnValue: AuthenticationClientBuilderProtocol? = nil
DispatchQueue.main.sync {
returnValue = makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue = newValue
}
}
}
}
var makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksClosure: ((SessionDirectories, String, ClientSessionDelegate, AppSettings, AppHooks) -> AuthenticationClientBuilderProtocol)?
func makeBuilder(sessionDirectories: SessionDirectories, passphrase: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks) -> AuthenticationClientBuilderProtocol {
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount += 1
makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments = (sessionDirectories: sessionDirectories, passphrase: passphrase, clientSessionDelegate: clientSessionDelegate, appSettings: appSettings, appHooks: appHooks)
DispatchQueue.main.async {
self.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedInvocations.append((sessionDirectories: sessionDirectories, passphrase: passphrase, clientSessionDelegate: clientSessionDelegate, appSettings: appSettings, appHooks: appHooks))
}
if let makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksClosure = makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksClosure {
return makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksClosure(sessionDirectories, passphrase, clientSessionDelegate, appSettings, appHooks)
} else {
return makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReturnValue
}
}
}
class AuthenticationClientBuilderMock: AuthenticationClientBuilderProtocol {
//MARK: - build
var buildHomeserverAddressThrowableError: Error?
var buildHomeserverAddressUnderlyingCallsCount = 0
var buildHomeserverAddressCallsCount: Int {
get {
if Thread.isMainThread {
return buildHomeserverAddressUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildHomeserverAddressUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildHomeserverAddressUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildHomeserverAddressUnderlyingCallsCount = newValue
}
}
}
}
var buildHomeserverAddressCalled: Bool {
return buildHomeserverAddressCallsCount > 0
}
var buildHomeserverAddressReceivedHomeserverAddress: String?
var buildHomeserverAddressReceivedInvocations: [String] = []
var buildHomeserverAddressUnderlyingReturnValue: ClientProtocol!
var buildHomeserverAddressReturnValue: ClientProtocol! {
get {
if Thread.isMainThread {
return buildHomeserverAddressUnderlyingReturnValue
} else {
var returnValue: ClientProtocol? = nil
DispatchQueue.main.sync {
returnValue = buildHomeserverAddressUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildHomeserverAddressUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
buildHomeserverAddressUnderlyingReturnValue = newValue
}
}
}
}
var buildHomeserverAddressClosure: ((String) async throws -> ClientProtocol)?
func build(homeserverAddress: String) async throws -> ClientProtocol {
if let error = buildHomeserverAddressThrowableError {
throw error
}
buildHomeserverAddressCallsCount += 1
buildHomeserverAddressReceivedHomeserverAddress = homeserverAddress
DispatchQueue.main.async {
self.buildHomeserverAddressReceivedInvocations.append(homeserverAddress)
}
if let buildHomeserverAddressClosure = buildHomeserverAddressClosure {
return try await buildHomeserverAddressClosure(homeserverAddress)
} else {
return buildHomeserverAddressReturnValue
}
}
//MARK: - buildWithQRCode
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerThrowableError: Error?
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingCallsCount = 0
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerCallsCount: Int {
get {
if Thread.isMainThread {
return buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingCallsCount = newValue
}
}
}
}
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerCalled: Bool {
return buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerCallsCount > 0
}
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReceivedArguments: (qrCodeData: QrCodeData, oidcConfiguration: OIDCConfigurationProxy, progressListener: QrLoginProgressListenerProxy)?
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReceivedInvocations: [(qrCodeData: QrCodeData, oidcConfiguration: OIDCConfigurationProxy, progressListener: QrLoginProgressListenerProxy)] = []
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingReturnValue: ClientProtocol!
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReturnValue: ClientProtocol! {
get {
if Thread.isMainThread {
return buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingReturnValue
} else {
var returnValue: ClientProtocol? = nil
DispatchQueue.main.sync {
returnValue = buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerUnderlyingReturnValue = newValue
}
}
}
}
var buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerClosure: ((QrCodeData, OIDCConfigurationProxy, QrLoginProgressListenerProxy) async throws -> ClientProtocol)?
func buildWithQRCode(qrCodeData: QrCodeData, oidcConfiguration: OIDCConfigurationProxy, progressListener: QrLoginProgressListenerProxy) async throws -> ClientProtocol {
if let error = buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerThrowableError {
throw error
}
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerCallsCount += 1
buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReceivedArguments = (qrCodeData: qrCodeData, oidcConfiguration: oidcConfiguration, progressListener: progressListener)
DispatchQueue.main.async {
self.buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReceivedInvocations.append((qrCodeData: qrCodeData, oidcConfiguration: oidcConfiguration, progressListener: progressListener))
}
if let buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerClosure = buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerClosure {
return try await buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerClosure(qrCodeData, oidcConfiguration, progressListener)
} else {
return buildWithQRCodeQrCodeDataOidcConfigurationProgressListenerReturnValue
}
}
}
class BugReportServiceMock: BugReportServiceProtocol {
var crashedLastRun: Bool {
get { return underlyingCrashedLastRun }
@@ -15296,6 +15520,271 @@ class UserSessionMock: UserSessionProtocol {
var underlyingCallbacks: PassthroughSubject<UserSessionCallback, Never>!
}
class UserSessionStoreMock: UserSessionStoreProtocol {
var hasSessions: Bool {
get { return underlyingHasSessions }
set(value) { underlyingHasSessions = value }
}
var underlyingHasSessions: Bool!
var userIDs: [String] = []
var clientSessionDelegate: ClientSessionDelegate {
get { return underlyingClientSessionDelegate }
set(value) { underlyingClientSessionDelegate = value }
}
var underlyingClientSessionDelegate: ClientSessionDelegate!
//MARK: - reset
var resetUnderlyingCallsCount = 0
var resetCallsCount: Int {
get {
if Thread.isMainThread {
return resetUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = resetUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
resetUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
resetUnderlyingCallsCount = newValue
}
}
}
}
var resetCalled: Bool {
return resetCallsCount > 0
}
var resetClosure: (() -> Void)?
func reset() {
resetCallsCount += 1
resetClosure?()
}
//MARK: - restoreUserSession
var restoreUserSessionUnderlyingCallsCount = 0
var restoreUserSessionCallsCount: Int {
get {
if Thread.isMainThread {
return restoreUserSessionUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = restoreUserSessionUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
restoreUserSessionUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
restoreUserSessionUnderlyingCallsCount = newValue
}
}
}
}
var restoreUserSessionCalled: Bool {
return restoreUserSessionCallsCount > 0
}
var restoreUserSessionUnderlyingReturnValue: Result<UserSessionProtocol, UserSessionStoreError>!
var restoreUserSessionReturnValue: Result<UserSessionProtocol, UserSessionStoreError>! {
get {
if Thread.isMainThread {
return restoreUserSessionUnderlyingReturnValue
} else {
var returnValue: Result<UserSessionProtocol, UserSessionStoreError>? = nil
DispatchQueue.main.sync {
returnValue = restoreUserSessionUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
restoreUserSessionUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
restoreUserSessionUnderlyingReturnValue = newValue
}
}
}
}
var restoreUserSessionClosure: (() async -> Result<UserSessionProtocol, UserSessionStoreError>)?
func restoreUserSession() async -> Result<UserSessionProtocol, UserSessionStoreError> {
restoreUserSessionCallsCount += 1
if let restoreUserSessionClosure = restoreUserSessionClosure {
return await restoreUserSessionClosure()
} else {
return restoreUserSessionReturnValue
}
}
//MARK: - userSession
var userSessionForSessionDirectoriesPassphraseUnderlyingCallsCount = 0
var userSessionForSessionDirectoriesPassphraseCallsCount: Int {
get {
if Thread.isMainThread {
return userSessionForSessionDirectoriesPassphraseUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = userSessionForSessionDirectoriesPassphraseUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
userSessionForSessionDirectoriesPassphraseUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
userSessionForSessionDirectoriesPassphraseUnderlyingCallsCount = newValue
}
}
}
}
var userSessionForSessionDirectoriesPassphraseCalled: Bool {
return userSessionForSessionDirectoriesPassphraseCallsCount > 0
}
var userSessionForSessionDirectoriesPassphraseReceivedArguments: (client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: String?)?
var userSessionForSessionDirectoriesPassphraseReceivedInvocations: [(client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: String?)] = []
var userSessionForSessionDirectoriesPassphraseUnderlyingReturnValue: Result<UserSessionProtocol, UserSessionStoreError>!
var userSessionForSessionDirectoriesPassphraseReturnValue: Result<UserSessionProtocol, UserSessionStoreError>! {
get {
if Thread.isMainThread {
return userSessionForSessionDirectoriesPassphraseUnderlyingReturnValue
} else {
var returnValue: Result<UserSessionProtocol, UserSessionStoreError>? = nil
DispatchQueue.main.sync {
returnValue = userSessionForSessionDirectoriesPassphraseUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
userSessionForSessionDirectoriesPassphraseUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
userSessionForSessionDirectoriesPassphraseUnderlyingReturnValue = newValue
}
}
}
}
var userSessionForSessionDirectoriesPassphraseClosure: ((ClientProtocol, SessionDirectories, String?) async -> Result<UserSessionProtocol, UserSessionStoreError>)?
func userSession(for client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError> {
userSessionForSessionDirectoriesPassphraseCallsCount += 1
userSessionForSessionDirectoriesPassphraseReceivedArguments = (client: client, sessionDirectories: sessionDirectories, passphrase: passphrase)
DispatchQueue.main.async {
self.userSessionForSessionDirectoriesPassphraseReceivedInvocations.append((client: client, sessionDirectories: sessionDirectories, passphrase: passphrase))
}
if let userSessionForSessionDirectoriesPassphraseClosure = userSessionForSessionDirectoriesPassphraseClosure {
return await userSessionForSessionDirectoriesPassphraseClosure(client, sessionDirectories, passphrase)
} else {
return userSessionForSessionDirectoriesPassphraseReturnValue
}
}
//MARK: - logout
var logoutUserSessionUnderlyingCallsCount = 0
var logoutUserSessionCallsCount: Int {
get {
if Thread.isMainThread {
return logoutUserSessionUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = logoutUserSessionUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
logoutUserSessionUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
logoutUserSessionUnderlyingCallsCount = newValue
}
}
}
}
var logoutUserSessionCalled: Bool {
return logoutUserSessionCallsCount > 0
}
var logoutUserSessionReceivedUserSession: UserSessionProtocol?
var logoutUserSessionReceivedInvocations: [UserSessionProtocol] = []
var logoutUserSessionClosure: ((UserSessionProtocol) -> Void)?
func logout(userSession: UserSessionProtocol) {
logoutUserSessionCallsCount += 1
logoutUserSessionReceivedUserSession = userSession
DispatchQueue.main.async {
self.logoutUserSessionReceivedInvocations.append(userSession)
}
logoutUserSessionClosure?(userSession)
}
//MARK: - clearCache
var clearCacheForUnderlyingCallsCount = 0
var clearCacheForCallsCount: Int {
get {
if Thread.isMainThread {
return clearCacheForUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = clearCacheForUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
clearCacheForUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
clearCacheForUnderlyingCallsCount = newValue
}
}
}
}
var clearCacheForCalled: Bool {
return clearCacheForCallsCount > 0
}
var clearCacheForReceivedUserID: String?
var clearCacheForReceivedInvocations: [String] = []
var clearCacheForClosure: ((String) -> Void)?
func clearCache(for userID: String) {
clearCacheForCallsCount += 1
clearCacheForReceivedUserID = userID
DispatchQueue.main.async {
self.clearCacheForReceivedInvocations.append(userID)
}
clearCacheForClosure?(userID)
}
}
class VoiceMessageCacheMock: VoiceMessageCacheProtocol {
var urlForRecording: URL {
get { return underlyingUrlForRecording }
@@ -0,0 +1,75 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import MatrixRustSDK
extension ClientSDKMock {
struct Configuration {
// MARK: Authentication
var serverAddress = "matrix.org"
var homeserverURL = "https://matrix-client.matrix.org"
var slidingSyncVersion = SlidingSyncVersion.native
var oidcLoginURL: String?
var supportsPasswordLogin = true
var elementWellKnown = "{\"registration_helper_url\":\"https://develop.element.io/#/mobile_register\"}"
var validCredentials = (username: "alice", password: "12345678")
// MARK: Session
var userID: String?
var session = Session(accessToken: UUID().uuidString,
refreshToken: nil,
userId: "@alice:matrix.org",
deviceId: UUID().uuidString,
homeserverUrl: "https://matrix-client.matrix.org",
oidcData: nil,
slidingSyncVersion: .native)
}
enum MockError: Error { case generic }
convenience init(configuration: Configuration) {
self.init()
homeserverLoginDetailsReturnValue = HomeserverLoginDetailsSDKMock(configuration: configuration)
slidingSyncVersionReturnValue = configuration.slidingSyncVersion
userIdServerNameThrowableError = MockError.generic
serverReturnValue = "https://\(configuration.serverAddress)"
getUrlUrlReturnValue = configuration.elementWellKnown
urlForOidcLoginOidcConfigurationReturnValue = OidcAuthorizationDataSDKMock(configuration: configuration)
loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { username, password, _, _ in
guard username == configuration.validCredentials.username,
password == configuration.validCredentials.password else {
throw MockError.generic // use the matrix error
}
}
userIdReturnValue = configuration.userID
sessionReturnValue = configuration.session
}
}
extension HomeserverLoginDetailsSDKMock {
convenience init(configuration: ClientSDKMock.Configuration) {
self.init()
slidingSyncVersionReturnValue = configuration.slidingSyncVersion
supportsPasswordLoginReturnValue = configuration.supportsPasswordLogin
supportsOidcLoginReturnValue = configuration.oidcLoginURL != nil
urlReturnValue = configuration.homeserverURL
}
}
extension OidcAuthorizationDataSDKMock {
convenience init(configuration: ClientSDKMock.Configuration) {
self.init()
loginUrlReturnValue = configuration.oidcLoginURL
}
}
@@ -0,0 +1,17 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
extension UserSessionStoreMock {
struct Configuration { }
convenience init(configuration: Configuration) {
self.init()
userSessionForSessionDirectoriesPassphraseReturnValue = .success(UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))))
clientSessionDelegate = KeychainControllerMock()
}
}
@@ -25,6 +25,11 @@ struct LoginHomeserver: Equatable {
self.registrationHelperURL = registrationHelperURL
}
/// Whether or not the app is able to register on this homeserver.
var supportsRegistration: Bool {
loginMode == .oidc || (address == "matrix.org" && registrationHelperURL != nil)
}
/// Sanitizes a user entered homeserver address with the following rules
/// - Trim any whitespace.
/// - Lowercase the address.
@@ -145,7 +145,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
startLoading(isInteractionBlocking: false)
Task {
switch await authenticationService.configure(for: homeserverDomain) {
switch await authenticationService.configure(for: homeserverDomain, flow: .login) {
case .success:
stopLoading()
if authenticationService.homeserver.value.loginMode == .oidc {
@@ -11,6 +11,8 @@ import SwiftUI
struct ServerConfirmationScreenCoordinatorParameters {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let slidingSyncLearnMoreURL: URL
let userIndicatorController: UserIndicatorControllerProtocol
}
enum ServerConfirmationScreenCoordinatorAction {
@@ -29,7 +31,9 @@ final class ServerConfirmationScreenCoordinator: CoordinatorProtocol {
init(parameters: ServerConfirmationScreenCoordinatorParameters) {
viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService,
authenticationFlow: parameters.authenticationFlow)
authenticationFlow: parameters.authenticationFlow,
slidingSyncLearnMoreURL: parameters.slidingSyncLearnMoreURL,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
@@ -19,11 +19,11 @@ struct ServerConfirmationScreenViewState: BindableState {
var homeserverAddress: String
/// The flow being attempted on the selected homeserver.
let authenticationFlow: AuthenticationFlow
/// Whether or not the homeserver supports registration.
var homeserverSupportsRegistration = false
/// The presentation anchor used for OIDC authentication.
var window: UIWindow?
var bindings = ServerConfirmationScreenBindings()
/// The screen's title.
var title: String {
switch authenticationFlow {
@@ -46,23 +46,16 @@ struct ServerConfirmationScreenViewState: BindableState {
""
}
case .register:
if canContinue {
L10n.screenServerConfirmationMessageRegister
} else {
L10n.errorAccountCreationNotPossible
}
}
}
/// Whether or not it is valid to continue the flow.
var canContinue: Bool {
switch authenticationFlow {
case .login: true
case .register: homeserverSupportsRegistration
L10n.screenServerConfirmationMessageRegister
}
}
}
struct ServerConfirmationScreenBindings {
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<ServerConfirmationScreenAlert>?
}
enum ServerConfirmationScreenViewAction {
/// Updates the window used as the OIDC presentation anchor.
case updateWindow(UIWindow)
@@ -71,3 +64,16 @@ enum ServerConfirmationScreenViewAction {
/// The user would like to change to a different homeserver.
case changeServer
}
enum ServerConfirmationScreenAlert: Hashable {
/// An alert that informs the user that a server could not be found.
case homeserverNotFound
/// An alert that informs the user about a bad well-known file.
case invalidWellKnown(String)
/// An alert that allows the user to learn about sliding sync.
case slidingSync
/// An alert that informs the user that registration isn't supported.
case registration
/// An unknown error has occurred.
case unknownError
}
@@ -11,46 +11,118 @@ import SwiftUI
typealias ServerConfirmationScreenViewModelType = StateStoreViewModel<ServerConfirmationScreenViewState, ServerConfirmationScreenViewAction>
class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol {
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let slidingSyncLearnMoreURL: URL
let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<ServerConfirmationScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(authenticationService: AuthenticationServiceProtocol, authenticationFlow: AuthenticationFlow) {
let homeserver = authenticationService.homeserver.value
init(authenticationService: AuthenticationServiceProtocol,
authenticationFlow: AuthenticationFlow,
slidingSyncLearnMoreURL: URL,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.authenticationFlow = authenticationFlow
self.slidingSyncLearnMoreURL = slidingSyncLearnMoreURL
self.userIndicatorController = userIndicatorController
let homeserver = authenticationService.homeserver.value
super.init(initialViewState: ServerConfirmationScreenViewState(homeserverAddress: homeserver.address,
authenticationFlow: authenticationFlow,
homeserverSupportsRegistration: homeserver.supportsRegistration))
authenticationFlow: authenticationFlow))
authenticationService.homeserver
.receive(on: DispatchQueue.main)
.sink { [weak self] homeserver in
guard let self else { return }
state.homeserverAddress = homeserver.address
state.homeserverSupportsRegistration = homeserver.supportsRegistration
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: ServerConfirmationScreenViewAction) {
switch viewAction {
case .updateWindow(let window):
guard state.window != window else { return }
Task { state.window = window }
case .confirm:
actionsSubject.send(.confirm)
Task { await configureAndContinue() }
case .changeServer:
actionsSubject.send(.changeServer)
}
}
}
extension LoginHomeserver {
var supportsRegistration: Bool {
loginMode == .oidc || (address == "matrix.org" && registrationHelperURL != nil)
// MARK: - Private
private func configureAndContinue() async {
let homeserver = authenticationService.homeserver.value
// If the login mode is unknown, the service hasn't be configured and we need to do it now.
// Otherwise we can continue the flow as server selection has been performed and succeeded.
guard homeserver.loginMode == .unknown || authenticationService.flow != authenticationFlow else {
actionsSubject.send(.confirm)
return
}
startLoading()
defer { stopLoading() }
switch await authenticationService.configure(for: homeserver.address, flow: authenticationFlow) {
case .success:
actionsSubject.send(.confirm)
case .failure(let error):
switch error {
case .invalidServer, .invalidHomeserverAddress:
displayError(.homeserverNotFound)
case .invalidWellKnown(let error):
displayError(.invalidWellKnown(error))
case .slidingSyncNotAvailable:
displayError(.slidingSync)
case .registrationNotSupported:
displayError(.registration)
default:
displayError(.unknownError)
}
}
}
private func startLoading(label: String = L10n.commonLoading) {
userIndicatorController.submitIndicator(UserIndicator(type: .modal,
title: label,
persistent: true))
}
private func stopLoading() {
userIndicatorController.retractAllIndicators()
}
private func displayError(_ type: ServerConfirmationScreenAlert) {
switch type {
case .homeserverNotFound:
state.bindings.alertInfo = AlertInfo(id: .homeserverNotFound,
title: L10n.errorUnknown,
message: L10n.screenChangeServerErrorInvalidHomeserver)
case .invalidWellKnown(let error):
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnown(error),
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorInvalidWellKnown(error))
case .slidingSync:
let openURL = { UIApplication.shared.open(self.slidingSyncLearnMoreURL) }
state.bindings.alertInfo = AlertInfo(id: .slidingSync,
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorNoSlidingSyncMessage,
primaryButton: .init(title: L10n.actionLearnMore, role: .cancel, action: openURL),
secondaryButton: .init(title: L10n.actionCancel, action: nil))
case .registration:
state.bindings.alertInfo = AlertInfo(id: .registration,
title: L10n.errorUnknown,
message: L10n.errorAccountCreationNotPossible)
case .unknownError:
state.bindings.alertInfo = AlertInfo(id: .unknownError)
}
}
}
@@ -19,6 +19,7 @@ struct ServerConfirmationScreen: View {
}
.background()
.backgroundStyle(.compound.bgCanvasDefault)
.alert(item: $context.alertInfo)
.introspect(.window, on: .supportedVersions) { window in
context.send(viewAction: .updateWindow(window))
}
@@ -53,7 +54,6 @@ struct ServerConfirmationScreen: View {
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.continue)
.disabled(!context.viewState.canContinue)
Button { context.send(viewAction: .changeServer) } label: {
Text(L10n.screenServerConfirmationChangeServer)
@@ -68,10 +68,8 @@ struct ServerConfirmationScreen: View {
// MARK: - Previews
struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
static let loginViewModel = ServerConfirmationScreenViewModel(authenticationService: MockAuthenticationService(),
authenticationFlow: .login)
static let registerViewModel = ServerConfirmationScreenViewModel(authenticationService: MockAuthenticationService(),
authenticationFlow: .register)
static let loginViewModel = makeViewModel(flow: .login)
static let registerViewModel = makeViewModel(flow: .register)
static var previews: some View {
NavigationStack {
@@ -86,4 +84,11 @@ struct ServerConfirmationScreen_Previews: PreviewProvider, TestablePreview {
}
.previewDisplayName("Register")
}
static func makeViewModel(flow: AuthenticationFlow) -> ServerConfirmationScreenViewModel {
ServerConfirmationScreenViewModel(authenticationService: AuthenticationService.mock,
authenticationFlow: flow,
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -11,30 +11,22 @@ enum MockServerSelectionScreenState: CaseIterable {
case matrix
case emptyAddress
case invalidAddress
case nonModal
/// Generate the view struct for the screen state.
@MainActor var viewModel: ServerSelectionScreenViewModel {
switch self {
case .matrix:
return ServerSelectionScreenViewModel(homeserverAddress: "https://matrix.org",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: true)
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)
case .emptyAddress:
return ServerSelectionScreenViewModel(homeserverAddress: "",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: true)
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)
case .invalidAddress:
let viewModel = ServerSelectionScreenViewModel(homeserverAddress: "thisisbad",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: true)
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL)
viewModel.displayError(.footerMessage(L10n.errorUnknown))
return viewModel
case .nonModal:
return ServerSelectionScreenViewModel(homeserverAddress: "https://matrix.org",
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: false)
}
}
}
@@ -11,9 +11,9 @@ import SwiftUI
struct ServerSelectionScreenCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol
let authenticationFlow: AuthenticationFlow
let slidingSyncLearnMoreURL: URL
let userIndicatorController: UserIndicatorControllerProtocol
/// Whether the screen is presented modally or within a navigation stack.
let isModallyPresented: Bool
}
enum ServerSelectionScreenCoordinatorAction {
@@ -38,8 +38,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
init(parameters: ServerSelectionScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ServerSelectionScreenViewModel(homeserverAddress: parameters.authenticationService.homeserver.value.address,
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
isModallyPresented: parameters.isModallyPresented)
slidingSyncLearnMoreURL: parameters.slidingSyncLearnMoreURL)
userIndicatorController = parameters.userIndicatorController
}
@@ -85,7 +84,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
startLoading()
Task {
switch await authenticationService.configure(for: homeserverAddress) {
switch await authenticationService.configure(for: homeserverAddress, flow: parameters.authenticationFlow) {
case .success:
MXLog.info("Selected homeserver: \(homeserverAddress)")
actionsSubject.send(.updated)
@@ -107,6 +106,8 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
viewModel.displayError(.invalidWellKnownAlert(error))
case .slidingSyncNotAvailable:
viewModel.displayError(.slidingSyncAlert)
case .registrationNotSupported:
viewModel.displayError(.registrationAlert) // TODO: [DOUG] Test me!
default:
viewModel.displayError(.footerMessage(L10n.errorUnknown))
}
@@ -22,19 +22,12 @@ struct ServerSelectionScreenViewState: BindableState {
var bindings: ServerSelectionScreenBindings
/// An error message to be shown in the text field footer.
var footerErrorMessage: String?
/// Whether the screen is presented modally or within a navigation stack.
var isModallyPresented: Bool
/// The message to show in the text field footer.
var footerMessage: AttributedString {
footerErrorMessage.map(AttributedString.init) ?? regularFooterMessage
}
/// The title shown on the confirm button.
var buttonTitle: String {
isModallyPresented ? L10n.actionContinue : L10n.actionNext
}
/// The text field is showing an error.
var isShowingFooterError: Bool {
footerErrorMessage != nil
@@ -45,10 +38,9 @@ struct ServerSelectionScreenViewState: BindableState {
bindings.homeserverAddress.isEmpty || isShowingFooterError
}
init(slidingSyncLearnMoreURL: URL, bindings: ServerSelectionScreenBindings, footerErrorMessage: String? = nil, isModallyPresented: Bool) {
init(slidingSyncLearnMoreURL: URL, bindings: ServerSelectionScreenBindings, footerErrorMessage: String? = nil) {
self.bindings = bindings
self.footerErrorMessage = footerErrorMessage
self.isModallyPresented = isModallyPresented
let linkPlaceholder = "{link}"
var message = AttributedString(L10n.screenChangeServerFormNotice(linkPlaceholder))
@@ -82,4 +74,6 @@ enum ServerSelectionScreenErrorType: Hashable {
case invalidWellKnownAlert(String)
/// An alert that allows the user to learn about sliding sync.
case slidingSyncAlert
/// An alert that informs the user that registration isn't supported.
case registrationAlert
}
@@ -19,13 +19,12 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
actionsSubject.eraseToAnyPublisher()
}
init(homeserverAddress: String, slidingSyncLearnMoreURL: URL, isModallyPresented: Bool) {
init(homeserverAddress: String, slidingSyncLearnMoreURL: URL) {
self.slidingSyncLearnMoreURL = slidingSyncLearnMoreURL
let bindings = ServerSelectionScreenBindings(homeserverAddress: homeserverAddress)
super.init(initialViewState: ServerSelectionScreenViewState(slidingSyncLearnMoreURL: slidingSyncLearnMoreURL,
bindings: bindings,
isModallyPresented: isModallyPresented))
bindings: bindings))
}
override func process(viewAction: ServerSelectionScreenViewAction) {
@@ -46,7 +45,7 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
state.footerErrorMessage = message
}
case .invalidWellKnownAlert(let error):
state.bindings.alertInfo = AlertInfo(id: .slidingSyncAlert,
state.bindings.alertInfo = AlertInfo(id: .invalidWellKnownAlert(error),
title: L10n.commonServerNotSupported,
message: L10n.screenChangeServerErrorInvalidWellKnown(error))
case .slidingSyncAlert:
@@ -56,6 +55,10 @@ class ServerSelectionScreenViewModel: ServerSelectionScreenViewModelType, Server
message: L10n.screenChangeServerErrorNoSlidingSyncMessage,
primaryButton: .init(title: L10n.actionLearnMore, role: .cancel, action: openURL),
secondaryButton: .init(title: L10n.actionCancel, action: nil))
case .registrationAlert:
state.bindings.alertInfo = AlertInfo(id: .registrationAlert,
title: L10n.errorUnknown,
message: L10n.errorAccountCreationNotPossible)
}
}
@@ -64,7 +64,7 @@ struct ServerSelectionScreen: View {
.onSubmit(submit)
Button(action: submit) {
Text(context.viewState.buttonTitle)
Text(L10n.actionContinue)
}
.buttonStyle(.compound(.primary))
.disabled(context.viewState.hasValidationError)
@@ -72,15 +72,12 @@ struct ServerSelectionScreen: View {
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
if context.viewState.isModallyPresented {
Button { context.send(viewAction: .dismiss) } label: {
Text(L10n.actionCancel)
}
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
Button { context.send(viewAction: .dismiss) } label: {
Text(L10n.actionCancel)
}
.accessibilityIdentifier(A11yIdentifiers.changeServerScreen.dismiss)
}
}
@@ -8,8 +8,16 @@
import Foundation
import MatrixRustSDK
// sourcery: AutoMockable
protocol AuthenticationClientBuilderProtocol {
func build(homeserverAddress: String) async throws -> ClientProtocol
func buildWithQRCode(qrCodeData: QrCodeData,
oidcConfiguration: OIDCConfigurationProxy,
progressListener: QrLoginProgressListenerProxy) async throws -> ClientProtocol
}
/// A wrapper around `ClientBuilder` to share reusable code between Normal and QR logins.
struct AuthenticationClientBuilder {
struct AuthenticationClientBuilder: AuthenticationClientBuilderProtocol {
let sessionDirectories: SessionDirectories
let passphrase: String
let clientSessionDelegate: ClientSessionDelegate
@@ -18,7 +26,7 @@ struct AuthenticationClientBuilder {
let appHooks: AppHooks
/// Builds a Client for login using OIDC or password authentication.
func build(homeserverAddress: String) async throws -> Client {
func build(homeserverAddress: String) async throws -> ClientProtocol {
if appSettings.slidingSyncDiscovery == .forceNative {
return try await makeClientBuilder(slidingSync: .forceNative).serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress).build()
}
@@ -38,7 +46,7 @@ struct AuthenticationClientBuilder {
/// Builds a Client, authenticating with the given QR code data.
func buildWithQRCode(qrCodeData: QrCodeData,
oidcConfiguration: OIDCConfigurationProxy,
progressListener: QrLoginProgressListenerProxy) async throws -> Client {
progressListener: QrLoginProgressListenerProxy) async throws -> ClientProtocol {
if appSettings.slidingSyncDiscovery == .forceNative {
return try await makeClientBuilder(slidingSync: .forceNative).buildWithQrCode(qrCodeData: qrCodeData,
oidcConfiguration: oidcConfiguration.rustValue,
@@ -0,0 +1,33 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import MatrixRustSDK
// sourcery: AutoMockable
protocol AuthenticationClientBuilderFactoryProtocol {
func makeBuilder(sessionDirectories: SessionDirectories,
passphrase: String,
clientSessionDelegate: ClientSessionDelegate,
appSettings: AppSettings,
appHooks: AppHooks) -> AuthenticationClientBuilderProtocol
}
/// A wrapper around `ClientBuilder` to share reusable code between Normal and QR logins.
struct AuthenticationClientBuilderFactory: AuthenticationClientBuilderFactoryProtocol {
func makeBuilder(sessionDirectories: SessionDirectories,
passphrase: String,
clientSessionDelegate: ClientSessionDelegate,
appSettings: AppSettings,
appHooks: AppHooks) -> AuthenticationClientBuilderProtocol {
AuthenticationClientBuilder(sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
}
}
@@ -10,31 +10,39 @@ import Foundation
import MatrixRustSDK
class AuthenticationService: AuthenticationServiceProtocol {
private var client: Client?
private var client: ClientProtocol?
private var sessionDirectories: SessionDirectories
private let passphrase: String
private let clientBuilderFactory: AuthenticationClientBuilderFactoryProtocol
private let userSessionStore: UserSessionStoreProtocol
private let appSettings: AppSettings
private let appHooks: AppHooks
private let homeserverSubject: CurrentValueSubject<LoginHomeserver, Never>
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
private(set) var flow: AuthenticationFlow
init(userSessionStore: UserSessionStoreProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol, appSettings: AppSettings, appHooks: AppHooks) {
init(userSessionStore: UserSessionStoreProtocol,
encryptionKeyProvider: EncryptionKeyProviderProtocol,
clientBuilderFactory: AuthenticationClientBuilderFactoryProtocol = AuthenticationClientBuilderFactory(),
appSettings: AppSettings,
appHooks: AppHooks) {
sessionDirectories = .init()
passphrase = encryptionKeyProvider.generateKey().base64EncodedString()
self.clientBuilderFactory = clientBuilderFactory
self.userSessionStore = userSessionStore
self.appSettings = appSettings
self.appHooks = appHooks
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultHomeserverAddress,
loginMode: .unknown))
// When updating these, don't forget to update the reset method too.
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultHomeserverAddress, loginMode: .unknown))
flow = .login
}
// MARK: - Public
func configure(for homeserverAddress: String) async -> Result<Void, AuthenticationServiceError> {
func configure(for homeserverAddress: String, flow: AuthenticationFlow) async -> Result<Void, AuthenticationServiceError> {
do {
var homeserver = LoginHomeserver(address: homeserverAddress, loginMode: .unknown)
@@ -57,7 +65,12 @@ class AuthenticationService: AuthenticationServiceProtocol {
case .failure: nil
}
if flow == .register, !homeserver.supportsRegistration {
return .failure(.registrationNotSupported)
}
self.client = client
self.flow = flow
homeserverSubject.send(homeserver)
return .success(())
} catch ClientBuildError.WellKnownDeserializationError(let error) {
@@ -150,18 +163,24 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
}
func reset() {
homeserverSubject.send(LoginHomeserver(address: appSettings.defaultHomeserverAddress, loginMode: .unknown))
flow = .login
client = nil
}
// MARK: - Private
private func makeClientBuilder() -> AuthenticationClientBuilder {
private func makeClientBuilder() -> AuthenticationClientBuilderProtocol {
// Use a fresh session directory each time the user enters a different server
// so that caches (e.g. server versions) are always fresh for the new server.
rotateSessionDirectory()
return AuthenticationClientBuilder(sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: userSessionStore.clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
return clientBuilderFactory.makeBuilder(sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: userSessionStore.clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
}
private func rotateSessionDirectory() {
@@ -169,7 +188,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
sessionDirectories = .init()
}
private func userSession(for client: Client) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
private func userSession(for client: ClientProtocol) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
switch await userSessionStore.userSession(for: client, sessionDirectories: sessionDirectories, passphrase: passphrase) {
case .success(let clientProxy):
return .success(clientProxy)
@@ -178,3 +197,13 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
}
}
// MARK: - Mocks
extension AuthenticationService {
static var mock = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: AuthenticationClientBuilderFactoryMock(configuration: .init()),
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
}
@@ -16,7 +16,7 @@ enum AuthenticationFlow {
case register
}
enum AuthenticationServiceError: Error {
enum AuthenticationServiceError: Error, Equatable {
/// An error occurred during OIDC authentication.
case oidcError(OIDCError)
case invalidServer
@@ -24,6 +24,7 @@ enum AuthenticationServiceError: Error {
case invalidHomeserverAddress
case invalidWellKnown(String)
case slidingSyncNotAvailable
case registrationNotSupported
case accountDeactivated
case failedLoggingIn
case sessionTokenRefreshNotSupported
@@ -33,9 +34,11 @@ enum AuthenticationServiceError: Error {
protocol AuthenticationServiceProtocol {
/// The currently configured homeserver.
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { get }
/// The type of flow the service is currently configured with.
var flow: AuthenticationFlow { get }
/// Sets up the service for login on the specified homeserver address.
func configure(for homeserverAddress: String) async -> Result<Void, AuthenticationServiceError>
func configure(for homeserverAddress: String, flow: AuthenticationFlow) async -> Result<Void, AuthenticationServiceError>
/// Performs login using OIDC for the current homeserver.
func urlForOIDCLogin() async -> Result<OIDCAuthorizationDataProxy, AuthenticationServiceError>
/// Asks the SDK to abort an ongoing OIDC login if we didn't get a callback to complete the request with.
@@ -46,6 +49,9 @@ protocol AuthenticationServiceProtocol {
func login(username: String, password: String, initialDeviceName: String?, deviceID: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError>
/// Completes registration using the credentials obtained via the helper URL.
func completeWebRegistration(using credentials: WebRegistrationCredentials) async -> Result<UserSessionProtocol, AuthenticationServiceError>
/// Resets the current configuration requiring `configure(for:flow:)` to be called again.
func reset()
}
// MARK: - OIDC
@@ -1,65 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Foundation
import MatrixRustSDK
class MockAuthenticationService: AuthenticationServiceProtocol {
let validCredentials = (username: "alice", password: "12345678")
private let homeserverSubject: CurrentValueSubject<LoginHomeserver, Never>
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
init(homeserver: LoginHomeserver = .mockMatrixDotOrg) {
homeserverSubject = .init(homeserver)
}
func configure(for homeserverAddress: String) async -> Result<Void, AuthenticationServiceError> {
// Map the address to the mock homeservers
if LoginHomeserver.mockMatrixDotOrg.address.contains(homeserverAddress) {
homeserverSubject.send(.mockMatrixDotOrg)
return .success(())
} else if LoginHomeserver.mockOIDC.address.contains(homeserverAddress) {
homeserverSubject.send(.mockOIDC)
return .success(())
} else if LoginHomeserver.mockBasicServer.address.contains(homeserverAddress) {
homeserverSubject.send(.mockBasicServer)
return .success(())
} else if LoginHomeserver.mockUnsupported.address.contains(homeserverAddress) {
homeserverSubject.send(.mockUnsupported)
return .success(())
} else {
// Otherwise fail with an invalid server.
return .failure(.invalidServer)
}
}
func urlForOIDCLogin() async -> Result<OIDCAuthorizationDataProxy, AuthenticationServiceError> {
.failure(.oidcError(.notSupported))
}
func abortOIDCLogin(data: OIDCAuthorizationDataProxy) async { }
func loginWithOIDCCallback(_ callbackURL: URL, data: OIDCAuthorizationDataProxy) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
.failure(.oidcError(.notSupported))
}
func login(username: String, password: String, initialDeviceName: String?, deviceID: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
// Login only succeeds if the username and password match the valid credentials property
guard username == validCredentials.username, password == validCredentials.password else {
return .failure(.invalidCredentials)
}
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: username))))
return .success(userSession)
}
func completeWebRegistration(using credentials: WebRegistrationCredentials) async -> Result<any UserSessionProtocol, AuthenticationServiceError> {
.failure(.failedLoggingIn)
}
}
@@ -81,7 +81,7 @@ final class QRCodeLoginService: QRCodeLoginServiceProtocol {
sessionDirectories = .init()
}
private func userSession(for client: Client) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
private func userSession(for client: ClientProtocol) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
switch await userSessionStore.userSession(for: client, sessionDirectories: sessionDirectories, passphrase: passphrase) {
case .success(let session):
return .success(session)
@@ -60,7 +60,7 @@ class UserSessionStore: UserSessionStoreProtocol {
}
}
func userSession(for client: Client, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError> {
func userSession(for client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError> {
do {
let session = try client.session()
let userID = try client.userId()
@@ -146,7 +146,7 @@ class UserSessionStore: UserSessionStoreProtocol {
}
}
private func setupProxyForClient(_ client: Client) async -> ClientProxyProtocol {
private func setupProxyForClient(_ client: ClientProtocol) async -> ClientProxyProtocol {
await ClientProxy(client: client,
networkMonitor: networkMonitor,
appSettings: appSettings)
@@ -14,6 +14,7 @@ enum UserSessionStoreError: Error {
case failedSettingUpSession
}
// sourcery: AutoMockable
protocol UserSessionStoreProtocol {
/// Deletes all data stored in the shared container and keychain
func reset()
@@ -31,7 +32,7 @@ protocol UserSessionStoreProtocol {
func restoreUserSession() async -> Result<UserSessionProtocol, UserSessionStoreError>
/// Creates a user session for a new client from the SDK along with the passphrase used for the data stores.
func userSession(for client: Client, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError>
func userSession(for client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: String?) async -> Result<UserSessionProtocol, UserSessionStoreError>
/// Logs out of the specified session.
func logout(userSession: UserSessionProtocol)
@@ -109,22 +109,16 @@ class MockScreen: Identifiable {
lazy var coordinator: CoordinatorProtocol? = {
switch id {
case .login:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = LoginScreenCoordinator(parameters: .init(authenticationService: MockAuthenticationService(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .serverSelection:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: MockAuthenticationService(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
isModallyPresented: true))
let coordinator = ServerSelectionScreenCoordinator(parameters: .init(authenticationService: AuthenticationService.mock,
authenticationFlow: .login,
slidingSyncLearnMoreURL: ServiceLocator.shared.settings.slidingSyncLearnMoreURL,
userIndicatorController: ServiceLocator.shared.userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .authenticationFlow:
let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: MockAuthenticationService(),
let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: AuthenticationService.mock,
qrCodeLoginService: QRCodeLoginServiceMock(),
bugReportService: BugReportServiceMock(),
navigationRootCoordinator: navigationRootCoordinator,
@@ -20,7 +20,6 @@ enum UITestsScreenIdentifier: String {
case createPoll
case createRoom
case createRoomNoUsers
case login
case roomLayoutBottom
case roomLayoutMiddle
case roomLayoutTop