mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Verify Element X with an existing Element Classic account. (#5374)
* Read and import the secrets from ClassicAppAccounts. * Record snapshots. * Add some documentation, tidy up tests and fix the dismissal of the backup instructions. * Workaround flakey tests (the fulfilments weren't always firing). * Allow a custom Classic App deep link URL to be configured.
This commit is contained in:
@@ -9588,6 +9588,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CLASSIC_APP_DEEP_LINK_URL = "element://open";
|
||||||
CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector;
|
CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector;
|
||||||
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared";
|
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared";
|
||||||
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service";
|
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service";
|
||||||
@@ -9661,6 +9662,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CLASSIC_APP_DEEP_LINK_URL = "element://open";
|
||||||
CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector;
|
CLASSIC_APP_GROUP_IDENTIFIER = group.im.vector;
|
||||||
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared";
|
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER = "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared";
|
||||||
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service";
|
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER = "im.vector.app.encryption-manager-service";
|
||||||
|
|||||||
@@ -655,6 +655,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
classicAppManager: classicAppManager,
|
classicAppManager: classicAppManager,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
appHooks: appHooks)
|
appHooks: appHooks)
|
||||||
|
Task { await authenticationService.setupClassicAppAccountState() }
|
||||||
|
|
||||||
let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
|
let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
|
||||||
bugReportService: bugReportService,
|
bugReportService: bugReportService,
|
||||||
|
|||||||
@@ -259,11 +259,18 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showStartScreen(fromState: State, applying provisioningParameters: AccountProvisioningParameters? = nil) {
|
private func showStartScreen(fromState: State, applying provisioningParameters: AccountProvisioningParameters? = nil) {
|
||||||
|
let mediaProvider = authenticationService.classicAppAccount.map { account in
|
||||||
|
MediaProvider(mediaLoader: ClassicAppMediaLoader(classicAppAccount: account),
|
||||||
|
imageCache: .onlyInMemory,
|
||||||
|
homeserverReachabilityPublisher: appMediator.networkMonitor.reachabilityPublisher) // Close enough approximation
|
||||||
|
}
|
||||||
|
|
||||||
let parameters = AuthenticationStartScreenParameters(authenticationService: authenticationService,
|
let parameters = AuthenticationStartScreenParameters(authenticationService: authenticationService,
|
||||||
provisioningParameters: provisioningParameters,
|
provisioningParameters: provisioningParameters,
|
||||||
isBugReportServiceEnabled: bugReportService.isEnabled,
|
isBugReportServiceEnabled: bugReportService.isEnabled,
|
||||||
|
appMediator: appMediator,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
mediaProvider: nil, // Currently unused.
|
mediaProvider: mediaProvider,
|
||||||
userIndicatorController: userIndicatorController)
|
userIndicatorController: userIndicatorController)
|
||||||
let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters)
|
let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters)
|
||||||
|
|
||||||
@@ -319,8 +326,6 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
stateMachine.tryEvent(.confirmServer(.login))
|
stateMachine.tryEvent(.confirmServer(.login))
|
||||||
case .signedIn(let userSession):
|
case .signedIn(let userSession):
|
||||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||||
// Since the qr code login flow includes verification
|
|
||||||
appSettings.hasRunIdentityConfirmationOnboarding = true
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.stateMachine.tryEvent(.signedIn, userInfo: userSession)
|
self.stateMachine.tryEvent(.signedIn, userInfo: userSession)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ extension AuthenticationClientFactoryMock {
|
|||||||
struct Configuration {
|
struct Configuration {
|
||||||
var homeserverClients = [
|
var homeserverClients = [
|
||||||
"matrix.org": ClientSDKMock(configuration: .init()),
|
"matrix.org": ClientSDKMock(configuration: .init()),
|
||||||
|
"https://matrix-client.matrix.org": ClientSDKMock(configuration: .init()),
|
||||||
"example.com": ClientSDKMock(configuration: .init(serverAddress: "example.com",
|
"example.com": ClientSDKMock(configuration: .init(serverAddress: "example.com",
|
||||||
homeserverURL: "https://matrix.example.com",
|
homeserverURL: "https://matrix.example.com",
|
||||||
slidingSyncVersion: .native,
|
slidingSyncVersion: .native,
|
||||||
@@ -51,5 +52,12 @@ extension AuthenticationClientFactoryMock {
|
|||||||
}
|
}
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure = { address, _, _, _ in
|
||||||
|
guard let client = configuration.homeserverClients[address] else {
|
||||||
|
throw ClientBuildError.ServerUnreachable(message: "Not a known homeserver.")
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,18 @@ import Foundation
|
|||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
|
||||||
extension ClassicAppManagerMock {
|
extension ClassicAppManagerMock {
|
||||||
struct Configuration { }
|
struct Configuration {
|
||||||
|
var accounts: [ClassicAppAccount]
|
||||||
|
var availableSecrets: ClassicAppAccount.AvailableSecrets = .complete
|
||||||
|
var secretsBundle: SecretsBundleWithUserId?
|
||||||
|
}
|
||||||
|
|
||||||
convenience init(_ configuration: Configuration) {
|
convenience init(_ configuration: Configuration) {
|
||||||
self.init()
|
self.init()
|
||||||
|
|
||||||
|
loadAccountsClosure = { configuration.accounts }
|
||||||
|
availableSecretsForReturnValue = configuration.availableSecrets
|
||||||
|
secretsBundleForReturnValue = configuration.secretsBundle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +30,7 @@ extension ClassicAppAccount {
|
|||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
avatarURL: nil,
|
avatarURL: nil,
|
||||||
serverName: "matrix.org",
|
serverName: "matrix.org",
|
||||||
homeserverURL: "https://matrix-client.matrix.org/",
|
homeserverURL: "https://matrix-client.matrix.org",
|
||||||
cryptoStoreURL: .cachesDirectory,
|
cryptoStoreURL: .cachesDirectory,
|
||||||
cryptoStorePassphrase: "1234567890",
|
cryptoStorePassphrase: "1234567890",
|
||||||
accessToken: "accessToken")
|
accessToken: "accessToken")
|
||||||
@@ -33,7 +41,7 @@ extension ClassicAppAccount {
|
|||||||
displayName: "Dan",
|
displayName: "Dan",
|
||||||
avatarURL: .mockMXCUserAvatar,
|
avatarURL: .mockMXCUserAvatar,
|
||||||
serverName: "matrix.org",
|
serverName: "matrix.org",
|
||||||
homeserverURL: "https://matrix-client.matrix.org/",
|
homeserverURL: "https://matrix-client.matrix.org",
|
||||||
cryptoStoreURL: .cachesDirectory,
|
cryptoStoreURL: .cachesDirectory,
|
||||||
cryptoStorePassphrase: "1234567890",
|
cryptoStorePassphrase: "1234567890",
|
||||||
accessToken: "accessToken")
|
accessToken: "accessToken")
|
||||||
|
|||||||
@@ -1946,6 +1946,80 @@ class AuthenticationClientFactoryMock: AuthenticationClientFactoryProtocol, @unc
|
|||||||
return makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReturnValue
|
return makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReturnValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//MARK: - makeInMemoryClient
|
||||||
|
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksThrowableError: Error?
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = 0
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCallsCount: Int {
|
||||||
|
get {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount
|
||||||
|
} else {
|
||||||
|
var returnValue: Int? = nil
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
returnValue = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = newValue
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingCallsCount = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCalled: Bool {
|
||||||
|
return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCallsCount > 0
|
||||||
|
}
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedArguments: (homeserverAddress: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks)?
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedInvocations: [(homeserverAddress: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks)] = []
|
||||||
|
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue: ClientProtocol!
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReturnValue: ClientProtocol! {
|
||||||
|
get {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue
|
||||||
|
} else {
|
||||||
|
var returnValue: ClientProtocol? = nil
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
returnValue = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue = newValue
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksUnderlyingReturnValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure: ((String, ClientSessionDelegate, AppSettings, AppHooks) async throws -> ClientProtocol)?
|
||||||
|
|
||||||
|
func makeInMemoryClient(homeserverAddress: String, clientSessionDelegate: ClientSessionDelegate, appSettings: AppSettings, appHooks: AppHooks) async throws -> ClientProtocol {
|
||||||
|
if let error = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksThrowableError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksCallsCount += 1
|
||||||
|
makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedArguments = (homeserverAddress: homeserverAddress, clientSessionDelegate: clientSessionDelegate, appSettings: appSettings, appHooks: appHooks)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReceivedInvocations.append((homeserverAddress: homeserverAddress, clientSessionDelegate: clientSessionDelegate, appSettings: appSettings, appHooks: appHooks))
|
||||||
|
}
|
||||||
|
if let makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure = makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure {
|
||||||
|
return try await makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksClosure(homeserverAddress, clientSessionDelegate, appSettings, appHooks)
|
||||||
|
} else {
|
||||||
|
return makeInMemoryClientHomeserverAddressClientSessionDelegateAppSettingsAppHooksReturnValue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
class BannedRoomProxyMock: BannedRoomProxyProtocol, @unchecked Sendable {
|
class BannedRoomProxyMock: BannedRoomProxyProtocol, @unchecked Sendable {
|
||||||
var info: BaseRoomInfoProxyProtocol {
|
var info: BaseRoomInfoProxyProtocol {
|
||||||
@@ -2309,6 +2383,154 @@ class ClassicAppManagerMock: ClassicAppManagerProtocol, @unchecked Sendable {
|
|||||||
return loadAccountsReturnValue
|
return loadAccountsReturnValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//MARK: - availableSecrets
|
||||||
|
|
||||||
|
var availableSecretsForThrowableError: Error?
|
||||||
|
var availableSecretsForUnderlyingCallsCount = 0
|
||||||
|
var availableSecretsForCallsCount: Int {
|
||||||
|
get {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return availableSecretsForUnderlyingCallsCount
|
||||||
|
} else {
|
||||||
|
var returnValue: Int? = nil
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
returnValue = availableSecretsForUnderlyingCallsCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
availableSecretsForUnderlyingCallsCount = newValue
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
availableSecretsForUnderlyingCallsCount = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var availableSecretsForCalled: Bool {
|
||||||
|
return availableSecretsForCallsCount > 0
|
||||||
|
}
|
||||||
|
var availableSecretsForReceivedAccount: ClassicAppAccount?
|
||||||
|
var availableSecretsForReceivedInvocations: [ClassicAppAccount] = []
|
||||||
|
|
||||||
|
var availableSecretsForUnderlyingReturnValue: ClassicAppAccount.AvailableSecrets!
|
||||||
|
var availableSecretsForReturnValue: ClassicAppAccount.AvailableSecrets! {
|
||||||
|
get {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return availableSecretsForUnderlyingReturnValue
|
||||||
|
} else {
|
||||||
|
var returnValue: ClassicAppAccount.AvailableSecrets? = nil
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
returnValue = availableSecretsForUnderlyingReturnValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
availableSecretsForUnderlyingReturnValue = newValue
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
availableSecretsForUnderlyingReturnValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var availableSecretsForClosure: ((ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets)?
|
||||||
|
|
||||||
|
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets {
|
||||||
|
if let error = availableSecretsForThrowableError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
availableSecretsForCallsCount += 1
|
||||||
|
availableSecretsForReceivedAccount = account
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.availableSecretsForReceivedInvocations.append(account)
|
||||||
|
}
|
||||||
|
if let availableSecretsForClosure = availableSecretsForClosure {
|
||||||
|
return try await availableSecretsForClosure(account)
|
||||||
|
} else {
|
||||||
|
return availableSecretsForReturnValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//MARK: - secretsBundle
|
||||||
|
|
||||||
|
var secretsBundleForThrowableError: Error?
|
||||||
|
var secretsBundleForUnderlyingCallsCount = 0
|
||||||
|
var secretsBundleForCallsCount: Int {
|
||||||
|
get {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return secretsBundleForUnderlyingCallsCount
|
||||||
|
} else {
|
||||||
|
var returnValue: Int? = nil
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
returnValue = secretsBundleForUnderlyingCallsCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
secretsBundleForUnderlyingCallsCount = newValue
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
secretsBundleForUnderlyingCallsCount = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var secretsBundleForCalled: Bool {
|
||||||
|
return secretsBundleForCallsCount > 0
|
||||||
|
}
|
||||||
|
var secretsBundleForReceivedAccount: ClassicAppAccount?
|
||||||
|
var secretsBundleForReceivedInvocations: [ClassicAppAccount] = []
|
||||||
|
|
||||||
|
var secretsBundleForUnderlyingReturnValue: SecretsBundleWithUserId!
|
||||||
|
var secretsBundleForReturnValue: SecretsBundleWithUserId! {
|
||||||
|
get {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return secretsBundleForUnderlyingReturnValue
|
||||||
|
} else {
|
||||||
|
var returnValue: SecretsBundleWithUserId? = nil
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
returnValue = secretsBundleForUnderlyingReturnValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
secretsBundleForUnderlyingReturnValue = newValue
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
secretsBundleForUnderlyingReturnValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var secretsBundleForClosure: ((ClassicAppAccount) async throws -> SecretsBundleWithUserId)?
|
||||||
|
|
||||||
|
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId {
|
||||||
|
if let error = secretsBundleForThrowableError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
secretsBundleForCallsCount += 1
|
||||||
|
secretsBundleForReceivedAccount = account
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.secretsBundleForReceivedInvocations.append(account)
|
||||||
|
}
|
||||||
|
if let secretsBundleForClosure = secretsBundleForClosure {
|
||||||
|
return try await secretsBundleForClosure(account)
|
||||||
|
} else {
|
||||||
|
return secretsBundleForReturnValue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
||||||
var actionsPublisher: AnyPublisher<ClientProxyAction, Never> {
|
var actionsPublisher: AnyPublisher<ClientProxyAction, Never> {
|
||||||
|
|||||||
@@ -46,11 +46,16 @@ extension ClientSDKMock {
|
|||||||
serverReturnValue = "https://\(configuration.serverAddress)"
|
serverReturnValue = "https://\(configuration.serverAddress)"
|
||||||
homeserverReturnValue = configuration.homeserverURL
|
homeserverReturnValue = configuration.homeserverURL
|
||||||
urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration)
|
urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReturnValue = OAuthAuthorizationDataSDKMock(configuration: configuration)
|
||||||
loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { username, password, _, _ in
|
loginUsernamePasswordInitialDeviceNameDeviceIdClosure = { [weak self] username, password, _, _ in
|
||||||
guard username == configuration.validCredentials.username,
|
guard username == configuration.validCredentials.username,
|
||||||
password == configuration.validCredentials.password else {
|
password == configuration.validCredentials.password else {
|
||||||
throw MockError.generic // use the matrix error
|
throw MockError.generic // use the matrix error
|
||||||
}
|
}
|
||||||
|
if username.hasPrefix("@"), username.contains(":") {
|
||||||
|
self?.userIdReturnValue = username
|
||||||
|
} else {
|
||||||
|
self?.userIdReturnValue = "@\(username):\(configuration.serverAddress)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userIdReturnValue = configuration.userID
|
userIdReturnValue = configuration.userID
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct InfoPlistReader {
|
|||||||
static let classicAppGroupIdentifier = "classicAppGroupIdentifier"
|
static let classicAppGroupIdentifier = "classicAppGroupIdentifier"
|
||||||
static let classicAppKeychainServiceIdentifier = "classicAppKeychainServiceIdentifier"
|
static let classicAppKeychainServiceIdentifier = "classicAppKeychainServiceIdentifier"
|
||||||
static let classicAppKeychainAccessGroupIdentifier = "classicAppKeychainAccessGroupIdentifier"
|
static let classicAppKeychainAccessGroupIdentifier = "classicAppKeychainAccessGroupIdentifier"
|
||||||
|
static let classicAppDeepLinkURL = "classicAppDeepLinkURL"
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Values {
|
private enum Values {
|
||||||
@@ -133,6 +134,11 @@ struct InfoPlistReader {
|
|||||||
infoPlistValue(forKey: Keys.classicAppKeychainAccessGroupIdentifier)
|
infoPlistValue(forKey: Keys.classicAppKeychainAccessGroupIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var classicAppDeepLinkURL: URL? {
|
||||||
|
let urlString: String? = infoPlistValue(forKey: Keys.classicAppDeepLinkURL)
|
||||||
|
return urlString.flatMap { URL(string: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
@_disfavoredOverload // Make sure optional types default to the optional version below.
|
@_disfavoredOverload // Make sure optional types default to the optional version below.
|
||||||
|
|||||||
+2
@@ -13,6 +13,7 @@ struct AuthenticationStartScreenParameters {
|
|||||||
let authenticationService: AuthenticationServiceProtocol
|
let authenticationService: AuthenticationServiceProtocol
|
||||||
let provisioningParameters: AccountProvisioningParameters?
|
let provisioningParameters: AccountProvisioningParameters?
|
||||||
let isBugReportServiceEnabled: Bool
|
let isBugReportServiceEnabled: Bool
|
||||||
|
let appMediator: AppMediatorProtocol
|
||||||
let appSettings: AppSettings
|
let appSettings: AppSettings
|
||||||
let mediaProvider: MediaProviderProtocol?
|
let mediaProvider: MediaProviderProtocol?
|
||||||
let userIndicatorController: UserIndicatorControllerProtocol
|
let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
@@ -41,6 +42,7 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
|
|||||||
viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
|
viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
|
||||||
provisioningParameters: parameters.provisioningParameters,
|
provisioningParameters: parameters.provisioningParameters,
|
||||||
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
|
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
|
||||||
|
appMediator: parameters.appMediator,
|
||||||
appSettings: parameters.appSettings,
|
appSettings: parameters.appSettings,
|
||||||
mediaProvider: parameters.mediaProvider,
|
mediaProvider: parameters.mediaProvider,
|
||||||
userIndicatorController: parameters.userIndicatorController)
|
userIndicatorController: parameters.userIndicatorController)
|
||||||
|
|||||||
+4
@@ -26,6 +26,9 @@ struct AuthenticationStartScreenViewState: BindableState {
|
|||||||
let showCreateAccountButton: Bool
|
let showCreateAccountButton: Bool
|
||||||
let showQRCodeLoginButton: Bool
|
let showQRCodeLoginButton: Bool
|
||||||
|
|
||||||
|
enum ClassicAppMode { case welcomeBack(ClassicAppAccount), otherOptions(ClassicAppAccount) }
|
||||||
|
var classicAppMode: ClassicAppMode?
|
||||||
|
|
||||||
let hideBrandChrome: Bool
|
let hideBrandChrome: Bool
|
||||||
|
|
||||||
var bindings = AuthenticationStartScreenViewStateBindings()
|
var bindings = AuthenticationStartScreenViewStateBindings()
|
||||||
@@ -62,4 +65,5 @@ enum AuthenticationStartScreenViewAction {
|
|||||||
case continueWithClassic(ClassicAppAccount)
|
case continueWithClassic(ClassicAppAccount)
|
||||||
case otherOptions(ClassicAppAccount)
|
case otherOptions(ClassicAppAccount)
|
||||||
case closeOtherOptions(ClassicAppAccount)
|
case closeOtherOptions(ClassicAppAccount)
|
||||||
|
case openClassicApp
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-6
@@ -14,6 +14,7 @@ typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<Authent
|
|||||||
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
|
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
|
||||||
private let authenticationService: AuthenticationServiceProtocol
|
private let authenticationService: AuthenticationServiceProtocol
|
||||||
private let provisioningParameters: AccountProvisioningParameters?
|
private let provisioningParameters: AccountProvisioningParameters?
|
||||||
|
private let appMediator: AppMediatorProtocol
|
||||||
private let appSettings: AppSettings
|
private let appSettings: AppSettings
|
||||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
|
|
||||||
@@ -28,16 +29,21 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
|||||||
init(authenticationService: AuthenticationServiceProtocol,
|
init(authenticationService: AuthenticationServiceProtocol,
|
||||||
provisioningParameters: AccountProvisioningParameters?,
|
provisioningParameters: AccountProvisioningParameters?,
|
||||||
isBugReportServiceEnabled: Bool,
|
isBugReportServiceEnabled: Bool,
|
||||||
|
appMediator: AppMediatorProtocol,
|
||||||
appSettings: AppSettings,
|
appSettings: AppSettings,
|
||||||
mediaProvider: MediaProviderProtocol?,
|
mediaProvider: MediaProviderProtocol?,
|
||||||
|
notificationCenter: NotificationCenter = .default,
|
||||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||||
self.authenticationService = authenticationService
|
self.authenticationService = authenticationService
|
||||||
self.provisioningParameters = provisioningParameters
|
self.provisioningParameters = provisioningParameters
|
||||||
|
self.appMediator = appMediator
|
||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
self.userIndicatorController = userIndicatorController
|
self.userIndicatorController = userIndicatorController
|
||||||
canReportProblem = isBugReportServiceEnabled
|
canReportProblem = isBugReportServiceEnabled
|
||||||
|
|
||||||
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
|
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
|
||||||
|
let classicAppAccountProvider = authenticationService.classicAppAccount?.serverName
|
||||||
|
let isClassicAppAccountAllowed = classicAppAccountProvider.map { appSettings.accountProviders.contains($0) } ?? false
|
||||||
|
|
||||||
let initialViewState = if !appSettings.allowOtherAccountProviders {
|
let initialViewState = if !appSettings.allowOtherAccountProviders {
|
||||||
// We don't show the create account button when custom providers are disallowed.
|
// We don't show the create account button when custom providers are disallowed.
|
||||||
@@ -45,22 +51,31 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
|||||||
AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
|
AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
|
||||||
showCreateAccountButton: false,
|
showCreateAccountButton: false,
|
||||||
showQRCodeLoginButton: isQRCodeScanningSupported,
|
showQRCodeLoginButton: isQRCodeScanningSupported,
|
||||||
|
classicAppMode: isClassicAppAccountAllowed ? authenticationService.classicAppAccount.map { .welcomeBack($0) } : nil,
|
||||||
hideBrandChrome: appSettings.hideBrandChrome)
|
hideBrandChrome: appSettings.hideBrandChrome)
|
||||||
} else if let provisioningParameters {
|
} else if let provisioningParameters {
|
||||||
// We only show the "Sign in to …" button when using a provisioning link.
|
// We only show the "Sign in to …" button when using a provisioning link.
|
||||||
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
|
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
|
||||||
showCreateAccountButton: false,
|
showCreateAccountButton: false,
|
||||||
showQRCodeLoginButton: false,
|
showQRCodeLoginButton: false,
|
||||||
|
classicAppMode: nil,
|
||||||
hideBrandChrome: appSettings.hideBrandChrome)
|
hideBrandChrome: appSettings.hideBrandChrome)
|
||||||
} else {
|
} else {
|
||||||
// The default configuration.
|
// The default configuration.
|
||||||
AuthenticationStartScreenViewState(serverName: nil,
|
AuthenticationStartScreenViewState(serverName: nil,
|
||||||
showCreateAccountButton: appSettings.showCreateAccountButton,
|
showCreateAccountButton: appSettings.showCreateAccountButton,
|
||||||
showQRCodeLoginButton: isQRCodeScanningSupported,
|
showQRCodeLoginButton: isQRCodeScanningSupported,
|
||||||
|
classicAppMode: authenticationService.classicAppAccount.map { .welcomeBack($0) },
|
||||||
hideBrandChrome: appSettings.hideBrandChrome)
|
hideBrandChrome: appSettings.hideBrandChrome)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.init(initialViewState: initialViewState, mediaProvider: mediaProvider)
|
super.init(initialViewState: initialViewState, mediaProvider: mediaProvider)
|
||||||
|
|
||||||
|
notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reloadClassicAppAccount()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func process(viewAction: AuthenticationStartScreenViewAction) {
|
override func process(viewAction: AuthenticationStartScreenViewAction) {
|
||||||
@@ -78,30 +93,52 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
|||||||
if canReportProblem {
|
if canReportProblem {
|
||||||
actionsSubject.send(.reportProblem)
|
actionsSubject.send(.reportProblem)
|
||||||
}
|
}
|
||||||
case .continueWithClassic, .otherOptions, .closeOtherOptions:
|
case .continueWithClassic(let account):
|
||||||
break // To follow.
|
Task { await login(classicAppAccount: account) }
|
||||||
|
case .otherOptions(let account):
|
||||||
|
state.classicAppMode = .otherOptions(account)
|
||||||
|
case .closeOtherOptions(let account):
|
||||||
|
state.classicAppMode = .welcomeBack(account)
|
||||||
|
case .openClassicApp:
|
||||||
|
guard let classicAppDeepLinkURL = InfoPlistReader.main.classicAppDeepLinkURL else { return }
|
||||||
|
appMediator.open(classicAppDeepLinkURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func login() async {
|
private func login(classicAppAccount: ClassicAppAccount? = nil) async {
|
||||||
if let serverName = state.serverName {
|
if let classicAppAccount {
|
||||||
|
if classicAppAccount.state.availableSecrets == .requiresBackup {
|
||||||
|
state.bindings.showClassicAppBackupInstructions = true
|
||||||
|
} else {
|
||||||
|
await configureAccountProvider(classicAppAccount.serverName,
|
||||||
|
loginHint: "mxid:\(classicAppAccount.userID)",
|
||||||
|
fallbackHomeserverURL: classicAppAccount.homeserverURL)
|
||||||
|
}
|
||||||
|
} else if let serverName = state.serverName {
|
||||||
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
|
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
|
||||||
} else {
|
} else {
|
||||||
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
|
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil) async {
|
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async {
|
||||||
startLoading()
|
startLoading()
|
||||||
defer { stopLoading() }
|
defer { stopLoading() }
|
||||||
|
|
||||||
guard case .success = await authenticationService.configure(for: accountProvider, flow: .login) else {
|
if case .failure = await authenticationService.configure(for: accountProvider, flow: .login) {
|
||||||
|
// Try the fallback URL before showing an error.
|
||||||
|
if let fallbackHomeserverURL,
|
||||||
|
case .success = await authenticationService.configure(for: fallbackHomeserverURL.absoluteString, flow: .login) {
|
||||||
|
// Fallback succeeded, continue with the flow.
|
||||||
|
} else {
|
||||||
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
|
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
|
||||||
|
// Element Classic accounts aren't shown for unsupported servers either, so nothing to do here.
|
||||||
displayError()
|
displayError()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
|
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
|
||||||
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
|
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
|
||||||
@@ -121,6 +158,23 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CancellableTask private var reloadClassicAppSecretsTask: Task<Void, Never>?
|
||||||
|
private func reloadClassicAppAccount() {
|
||||||
|
guard case let .welcomeBack(classicAppAccount) = state.classicAppMode else { return }
|
||||||
|
|
||||||
|
reloadClassicAppSecretsTask = Task { [weak self] in
|
||||||
|
await self?.authenticationService.refreshClassicAppAccountState()
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
if let availableSecrets = classicAppAccount.state.availableSecrets, availableSecrets != .requiresBackup {
|
||||||
|
await MainActor.run { self?.state.bindings.showClassicAppBackupInstructions = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Indicators
|
||||||
|
|
||||||
private let loadingIndicatorID = "\(AuthenticationStartScreenViewModel.self)-Loading"
|
private let loadingIndicatorID = "\(AuthenticationStartScreenViewModel.self)-Loading"
|
||||||
|
|
||||||
private func startLoading() {
|
private func startLoading() {
|
||||||
|
|||||||
+37
@@ -13,6 +13,10 @@ struct AuthenticationClassicAppAccountView: View {
|
|||||||
|
|
||||||
let classicAppAccount: ClassicAppAccount
|
let classicAppAccount: ClassicAppAccount
|
||||||
|
|
||||||
|
var isLoadingAccount: Bool {
|
||||||
|
classicAppAccount.state.isServerSupported == nil || classicAppAccount.state.availableSecrets == nil
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
FullscreenDialog(topPadding: 25, background: .gradient) {
|
FullscreenDialog(topPadding: 25, background: .gradient) {
|
||||||
VStack(spacing: 38) {
|
VStack(spacing: 38) {
|
||||||
@@ -28,6 +32,11 @@ struct AuthenticationClassicAppAccountView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
|
.sheet(isPresented: $context.showClassicAppBackupInstructions) {
|
||||||
|
AuthenticationClassicAppBackupInstructionsView(classicAppAccount: classicAppAccount) {
|
||||||
|
context.send(viewAction: .openClassicApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
.introspect(.window, on: .supportedVersions) { window in
|
.introspect(.window, on: .supportedVersions) { window in
|
||||||
context.send(viewAction: .updateWindow(window))
|
context.send(viewAction: .updateWindow(window))
|
||||||
}
|
}
|
||||||
@@ -73,6 +82,20 @@ struct AuthenticationClassicAppAccountView: View {
|
|||||||
|
|
||||||
var buttons: some View {
|
var buttons: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
if isLoadingAccount {
|
||||||
|
Button {
|
||||||
|
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
Text(L10n.screenOnboardingCheckingAccount)
|
||||||
|
} icon: {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.compound.iconOnSolidPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.compound(.primary))
|
||||||
|
.disabled(true)
|
||||||
|
} else {
|
||||||
Button(L10n.actionContinue) {
|
Button(L10n.actionContinue) {
|
||||||
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||||
}
|
}
|
||||||
@@ -85,6 +108,7 @@ struct AuthenticationClassicAppAccountView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension ClassicAppAccount {
|
private extension ClassicAppAccount {
|
||||||
var displayableName: String {
|
var displayableName: String {
|
||||||
@@ -102,17 +126,30 @@ private extension ClassicAppAccount {
|
|||||||
|
|
||||||
struct AuthenticationClassicAppAccountView_Previews: PreviewProvider { // Not Testable – snapshots generated by main screen.
|
struct AuthenticationClassicAppAccountView_Previews: PreviewProvider { // Not Testable – snapshots generated by main screen.
|
||||||
static let viewModel = makeViewModel()
|
static let viewModel = makeViewModel()
|
||||||
|
static let classicAppAccount = {
|
||||||
|
let account = ClassicAppAccount.mockDan
|
||||||
|
account.state.isServerSupported = true
|
||||||
|
account.state.availableSecrets = .complete
|
||||||
|
return account
|
||||||
|
}()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
|
ElementNavigationStack {
|
||||||
|
AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: classicAppAccount)
|
||||||
|
}
|
||||||
|
.previewDisplayName("Ready")
|
||||||
|
|
||||||
ElementNavigationStack {
|
ElementNavigationStack {
|
||||||
AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: .mockDan)
|
AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: .mockDan)
|
||||||
}
|
}
|
||||||
|
.previewDisplayName("Loading")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func makeViewModel() -> AuthenticationStartScreenViewModel {
|
static func makeViewModel() -> AuthenticationStartScreenViewModel {
|
||||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
||||||
provisioningParameters: nil,
|
provisioningParameters: nil,
|
||||||
isBugReportServiceEnabled: false,
|
isBugReportServiceEnabled: false,
|
||||||
|
appMediator: AppMediatorMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock())
|
userIndicatorController: UserIndicatorControllerMock())
|
||||||
|
|||||||
+31
-14
@@ -9,10 +9,19 @@ import Compound
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AuthenticationClassicAppBackupInstructionsView: View {
|
struct AuthenticationClassicAppBackupInstructionsView: View {
|
||||||
let context: AuthenticationStartScreenViewModel.Context
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let classicAppAccount: ClassicAppAccount
|
||||||
|
let openClassicAppAction: () -> Void
|
||||||
|
|
||||||
|
private var isRefreshingSecrets: Bool {
|
||||||
|
classicAppAccount.state.availableSecrets == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonTitle: String {
|
||||||
|
isRefreshingSecrets ? L10n.screenOnboardingCheckingAccount : L10n.screenMissingKeyBackupOpenElementClassic
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ElementNavigationStack {
|
ElementNavigationStack {
|
||||||
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
||||||
@@ -32,6 +41,7 @@ struct AuthenticationClassicAppBackupInstructionsView: View {
|
|||||||
TitleAndIcon(title: L10n.screenMissingKeyBackupTitle(InfoPlistReader.main.bundleDisplayName),
|
TitleAndIcon(title: L10n.screenMissingKeyBackupTitle(InfoPlistReader.main.bundleDisplayName),
|
||||||
icon: \.keySolid,
|
icon: \.keySolid,
|
||||||
iconStyle: .default)
|
iconStyle: .default)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
SFNumberedListView(items: [
|
SFNumberedListView(items: [
|
||||||
AttributedString(L10n.screenMissingKeyBackupStep1),
|
AttributedString(L10n.screenMissingKeyBackupStep1),
|
||||||
@@ -44,10 +54,18 @@ struct AuthenticationClassicAppBackupInstructionsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buttons: some View {
|
var buttons: some View {
|
||||||
Button(L10n.screenMissingKeyBackupOpenElementClassic) {
|
Button(action: openClassicAppAction) {
|
||||||
UIApplication.shared.open("element://open")
|
Label {
|
||||||
|
Text(buttonTitle)
|
||||||
|
} icon: {
|
||||||
|
if isRefreshingSecrets {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.compound.iconOnSolidPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.compound(.primary))
|
.buttonStyle(.compound(.primary))
|
||||||
|
.disabled(isRefreshingSecrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolbar: some ToolbarContent {
|
var toolbar: some ToolbarContent {
|
||||||
@@ -58,18 +76,17 @@ struct AuthenticationClassicAppBackupInstructionsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AuthenticationClassicAppBackupInstructionsView_Previews: PreviewProvider, TestablePreview {
|
struct AuthenticationClassicAppBackupInstructionsView_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = makeViewModel()
|
static let loadedAccount = {
|
||||||
|
let account = ClassicAppAccount.mockDan
|
||||||
|
account.state.availableSecrets = .requiresBackup
|
||||||
|
return account
|
||||||
|
}()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AuthenticationClassicAppBackupInstructionsView(context: viewModel.context)
|
AuthenticationClassicAppBackupInstructionsView(classicAppAccount: loadedAccount) { }
|
||||||
}
|
.previewDisplayName("Initial")
|
||||||
|
|
||||||
static func makeViewModel() -> AuthenticationStartScreenViewModel {
|
AuthenticationClassicAppBackupInstructionsView(classicAppAccount: .mockAlice) { }
|
||||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
.previewDisplayName("Refreshing")
|
||||||
provisioningParameters: nil,
|
|
||||||
isBugReportServiceEnabled: false,
|
|
||||||
appSettings: ServiceLocator.shared.settings,
|
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
|
||||||
userIndicatorController: UserIndicatorControllerMock())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-6
@@ -16,6 +16,15 @@ struct AuthenticationStartScreen: View {
|
|||||||
@Bindable var context: AuthenticationStartScreenViewModel.Context
|
@Bindable var context: AuthenticationStartScreenViewModel.Context
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if case let .welcomeBack(classicAppAccount) = context.viewState.classicAppMode,
|
||||||
|
classicAppAccount.state.isServerSupported != false {
|
||||||
|
AuthenticationClassicAppAccountView(context: context, classicAppAccount: classicAppAccount)
|
||||||
|
} else {
|
||||||
|
standardContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var standardContent: some View {
|
||||||
// This view uses a GeometryReader instead of FullscreenDialog so its content takes the full
|
// This view uses a GeometryReader instead of FullscreenDialog so its content takes the full
|
||||||
// height available (after taking the buttons out of the equation) in order for the logo
|
// height available (after taking the buttons out of the equation) in order for the logo
|
||||||
// and title to appear vertically centred and equally spaced within this content area.
|
// and title to appear vertically centred and equally spaced within this content area.
|
||||||
@@ -41,14 +50,12 @@ struct AuthenticationStartScreen: View {
|
|||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
|
||||||
.background {
|
.background {
|
||||||
AuthenticationStartScreenBackgroundImage()
|
AuthenticationStartScreenBackgroundImage()
|
||||||
}
|
}
|
||||||
|
.navigationBarHidden(context.viewState.classicAppMode == nil)
|
||||||
|
.toolbar { toolbar }
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
.sheet(isPresented: $context.showClassicAppBackupInstructions) {
|
|
||||||
AuthenticationClassicAppBackupInstructionsView(context: context)
|
|
||||||
}
|
|
||||||
.introspect(.window, on: .supportedVersions) { window in
|
.introspect(.window, on: .supportedVersions) { window in
|
||||||
context.send(viewAction: .updateWindow(window))
|
context.send(viewAction: .updateWindow(window))
|
||||||
}
|
}
|
||||||
@@ -132,6 +139,17 @@ struct AuthenticationStartScreen: View {
|
|||||||
let shortVersionString = ProcessInfo.isRunningTests ? "0.0.0" : InfoPlistReader.main.bundleShortVersionString
|
let shortVersionString = ProcessInfo.isRunningTests ? "0.0.0" : InfoPlistReader.main.bundleShortVersionString
|
||||||
return Text(L10n.screenOnboardingAppVersion(shortVersionString))
|
return Text(L10n.screenOnboardingAppVersion(shortVersionString))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
var toolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
if case let .otherOptions(classicAppAccount) = context.viewState.classicAppMode {
|
||||||
|
ToolbarButton(role: .close) {
|
||||||
|
context.send(viewAction: .closeOtherOptions(classicAppAccount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
@@ -139,18 +157,30 @@ struct AuthenticationStartScreen: View {
|
|||||||
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
|
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = makeViewModel()
|
static let viewModel = makeViewModel()
|
||||||
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
|
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
|
||||||
|
static let classicAppViewModel = makeViewModel(hasClassicAppAccount: true)
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AuthenticationStartScreen(context: viewModel.context)
|
AuthenticationStartScreen(context: viewModel.context)
|
||||||
.previewDisplayName("Default")
|
.previewDisplayName("Default")
|
||||||
AuthenticationStartScreen(context: provisionedViewModel.context)
|
AuthenticationStartScreen(context: provisionedViewModel.context)
|
||||||
.previewDisplayName("Provisioned")
|
.previewDisplayName("Provisioned")
|
||||||
|
|
||||||
|
ElementNavigationStack {
|
||||||
|
AuthenticationStartScreen(context: classicAppViewModel.context)
|
||||||
|
}
|
||||||
|
.previewDisplayName("Classic App")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func makeViewModel(provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel {
|
static func makeViewModel(provisionedServerName: String? = nil, hasClassicAppAccount: Bool = false) -> AuthenticationStartScreenViewModel {
|
||||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
let classicAppAccount = ClassicAppAccount.mockDan
|
||||||
|
classicAppAccount.state.isServerSupported = true
|
||||||
|
classicAppAccount.state.availableSecrets = .complete
|
||||||
|
let classicAppManager: ClassicAppManagerMock? = hasClassicAppAccount ? .init(.init(accounts: [classicAppAccount])) : nil
|
||||||
|
|
||||||
|
return AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock(classicAppManager: classicAppManager),
|
||||||
provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) },
|
provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) },
|
||||||
isBugReportServiceEnabled: true,
|
isBugReportServiceEnabled: true,
|
||||||
|
appMediator: AppMediatorMock(),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock())
|
userIndicatorController: UserIndicatorControllerMock())
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ protocol AuthenticationClientFactoryProtocol {
|
|||||||
clientSessionDelegate: ClientSessionDelegate,
|
clientSessionDelegate: ClientSessionDelegate,
|
||||||
appSettings: AppSettings,
|
appSettings: AppSettings,
|
||||||
appHooks: AppHooks) async throws -> ClientProtocol
|
appHooks: AppHooks) async throws -> ClientProtocol
|
||||||
|
|
||||||
|
func makeInMemoryClient(homeserverAddress: String,
|
||||||
|
clientSessionDelegate: ClientSessionDelegate,
|
||||||
|
appSettings: AppSettings,
|
||||||
|
appHooks: AppHooks) async throws -> ClientProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wrapper around `ClientBuilder` to allow for mocked clients to be injected into authentication tests.
|
/// A wrapper around `ClientBuilder` to allow for mocked clients to be injected into authentication tests.
|
||||||
@@ -40,4 +45,21 @@ struct AuthenticationClientFactory: AuthenticationClientFactoryProtocol {
|
|||||||
.serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress)
|
.serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeInMemoryClient(homeserverAddress: String,
|
||||||
|
clientSessionDelegate: ClientSessionDelegate,
|
||||||
|
appSettings: AppSettings,
|
||||||
|
appHooks: AppHooks) async throws -> ClientProtocol {
|
||||||
|
try await ClientBuilder
|
||||||
|
.baseBuilder(httpProxy: appSettings.websiteURL.globalProxy,
|
||||||
|
slidingSync: .discover,
|
||||||
|
sessionDelegate: clientSessionDelegate,
|
||||||
|
appHooks: appHooks,
|
||||||
|
enableOnlySignedDeviceIsolationMode: appSettings.enableOnlySignedDeviceIsolationMode,
|
||||||
|
enableKeyShareOnInvite: appSettings.enableKeyShareOnInvite,
|
||||||
|
threadsEnabled: appSettings.threadsEnabled)
|
||||||
|
.inMemoryStore()
|
||||||
|
.serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
|
|
||||||
private(set) var flow: AuthenticationFlow
|
private(set) var flow: AuthenticationFlow
|
||||||
|
|
||||||
|
let classicAppAccount: ClassicAppAccount?
|
||||||
|
|
||||||
init(userSessionStore: UserSessionStoreProtocol,
|
init(userSessionStore: UserSessionStoreProtocol,
|
||||||
encryptionKeyProvider: EncryptionKeyProviderProtocol,
|
encryptionKeyProvider: EncryptionKeyProviderProtocol,
|
||||||
classicAppManager: ClassicAppManagerProtocol?,
|
classicAppManager: ClassicAppManagerProtocol?,
|
||||||
@@ -45,14 +47,15 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
if let classicAppManager {
|
if let classicAppManager {
|
||||||
// Just let the app manager log the detected account for now.
|
classicAppAccount = try classicAppManager.loadAccounts().first
|
||||||
_ = try classicAppManager.loadAccounts()
|
|
||||||
} else {
|
} else {
|
||||||
MXLog.info("Classic App not configured, skipping loadAccounts.")
|
MXLog.info("Classic App not configured, skipping loadAccounts.")
|
||||||
|
classicAppAccount = nil
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// This should show an alert: "We have detected an older version of Element Classic, but no bueno!"
|
// No need to alert the user of the failure, just log it. They can still sign in manually.
|
||||||
MXLog.error("Failed loading accounts from the Classic app: \(error)")
|
MXLog.error("Failed loading accounts from the Classic app: \(error)")
|
||||||
|
classicAppAccount = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// When updating these, don't forget to update the reset method too.
|
// When updating these, don't forget to update the reset method too.
|
||||||
@@ -129,6 +132,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
guard let client else { return .failure(.failedLoggingIn) }
|
guard let client else { return .failure(.failedLoggingIn) }
|
||||||
do {
|
do {
|
||||||
try await client.loginWithOidcCallback(callbackUrl: callbackURL.absoluteString)
|
try await client.loginWithOidcCallback(callbackUrl: callbackURL.absoluteString)
|
||||||
|
await verifyClientIfPossible(client: client)
|
||||||
return await userSession(for: client)
|
return await userSession(for: client)
|
||||||
} catch OidcError.Cancelled {
|
} catch OidcError.Cancelled {
|
||||||
return .failure(.oidcError(.userCancellation))
|
return .failure(.oidcError(.userCancellation))
|
||||||
@@ -150,6 +154,8 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
return .failure(.sessionTokenRefreshNotSupported)
|
return .failure(.sessionTokenRefreshNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await verifyClientIfPossible(client: client)
|
||||||
|
|
||||||
return await userSession(for: client)
|
return await userSession(for: client)
|
||||||
} catch let ClientError.MatrixApi(errorKind, _, _, _) {
|
} catch let ClientError.MatrixApi(errorKind, _, _, _) {
|
||||||
MXLog.error("Failed logging in with error kind: \(errorKind)")
|
MXLog.error("Failed logging in with error kind: \(errorKind)")
|
||||||
@@ -203,6 +209,9 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue)
|
let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue)
|
||||||
try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener)
|
try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener)
|
||||||
|
|
||||||
|
// Since the QR code login flow includes verification.
|
||||||
|
appSettings.hasRunIdentityConfirmationOnboarding = true
|
||||||
|
|
||||||
switch await userSession(for: client) {
|
switch await userSession(for: client) {
|
||||||
case .success(let userSession):
|
case .success(let userSession):
|
||||||
progressSubject.send(.signedIn(userSession))
|
progressSubject.send(.signedIn(userSession))
|
||||||
@@ -260,6 +269,86 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
return .failure(.failedLoggingIn)
|
return .failure(.failedLoggingIn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Classic App
|
||||||
|
|
||||||
|
/// Populates the Classic app account's state by checking whether the account's homeserver is supported
|
||||||
|
/// (has Sliding Sync and OIDC or password login) and whether all of the required secrets are available.
|
||||||
|
func setupClassicAppAccountState() async {
|
||||||
|
guard let classicAppAccount, classicAppAccount.state.isServerSupported == nil else { return }
|
||||||
|
MXLog.info("Checking Classic app account: \(classicAppAccount)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let client = try await clientFactory.makeInMemoryClient(homeserverAddress: classicAppAccount.homeserverURL.absoluteString,
|
||||||
|
clientSessionDelegate: userSessionStore.clientSessionDelegate,
|
||||||
|
appSettings: appSettings,
|
||||||
|
appHooks: appHooks)
|
||||||
|
let loginDetails = await client.homeserverLoginDetails()
|
||||||
|
let isServerSupported = loginDetails.supportsOidcLogin() || loginDetails.supportsPasswordLogin()
|
||||||
|
MXLog.info("Classic app homeserver supported: \(isServerSupported)")
|
||||||
|
classicAppAccount.state.isServerSupported = isServerSupported
|
||||||
|
|
||||||
|
await refreshClassicAppAccountState()
|
||||||
|
} catch {
|
||||||
|
MXLog.info("Classic app account support check failed: \(error)")
|
||||||
|
classicAppAccount.state.isServerSupported = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks which encryption secrets are currently available from the Classic app and updates the account's state accordingly. We will handle the
|
||||||
|
/// Classic account differently, depending on which secrets are available:
|
||||||
|
/// - When they're `.complete` (the session is verified and has a key backup) we can automatically verify the account once signed in.
|
||||||
|
/// - When they're `.requiresBackup` we prompt the user to enable a key backup before signing in so that their messages can be decrypted.
|
||||||
|
/// - When they're `.unavailable` (an unverified session without secret storage) we simply show the Classic account to help the user sign in
|
||||||
|
/// faster but they will need to reset their identity and verify the Classic account themselves.
|
||||||
|
///
|
||||||
|
/// This should be called whenever the user has potentially updated their secrets in the Classic app.
|
||||||
|
func refreshClassicAppAccountState() async {
|
||||||
|
guard let classicAppManager, let classicAppAccount, classicAppAccount.state.isServerSupported != nil else { return }
|
||||||
|
|
||||||
|
classicAppAccount.state.availableSecrets = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let availableSecrets = try await classicAppManager.availableSecrets(for: classicAppAccount)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
MXLog.info("Classic app secrets: \(availableSecrets)")
|
||||||
|
classicAppAccount.state.availableSecrets = availableSecrets
|
||||||
|
} catch {
|
||||||
|
MXLog.info("Failed to refresh Classic app account secrets: \(error)")
|
||||||
|
classicAppAccount.state.availableSecrets = .unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports the Classic app's encryption secrets into the signed-in client, automatically verifying the session. This will no-op if
|
||||||
|
/// the user signed in with a different account or when the Classic app doesn't have a complete set of secrets (meaning either
|
||||||
|
/// key backup is disabled or the session hasn't been verified).
|
||||||
|
private func verifyClientIfPossible(client: ClientProtocol) async {
|
||||||
|
guard let classicAppManager, let classicAppAccount else { return }
|
||||||
|
|
||||||
|
// Technically the SDK makes sure the secrets are for the correct account, but as
|
||||||
|
// we want to verify the classic account regardless which flow was used, it seems
|
||||||
|
// sane to avoid loading the secrets when we know that they're not relevant.
|
||||||
|
guard classicAppAccount.userID == (try? client.userId()) else { return }
|
||||||
|
|
||||||
|
guard classicAppAccount.state.availableSecrets == .complete else {
|
||||||
|
MXLog.info("The matching Classic app account is missing secrets, ignoring.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
MXLog.info("Found matching Classic app account, importing secrets.")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let secrets = try await classicAppManager.secretsBundle(for: classicAppAccount)
|
||||||
|
try await client.encryption().importSecretsBundle(secretsBundle: secrets)
|
||||||
|
|
||||||
|
MXLog.info("Classic app account secrets imported.")
|
||||||
|
|
||||||
|
// Importing the secrets automatically verifies the session.
|
||||||
|
appSettings.hasRunIdentityConfirmationOnboarding = true
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Failed to import secrets for Classic app account: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension HumanQrLoginError {
|
private extension HumanQrLoginError {
|
||||||
@@ -291,9 +380,13 @@ private extension HumanQrLoginError {
|
|||||||
|
|
||||||
extension AuthenticationService {
|
extension AuthenticationService {
|
||||||
static var mock: AuthenticationService {
|
static var mock: AuthenticationService {
|
||||||
|
mock(classicAppManager: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mock(classicAppManager: ClassicAppManagerProtocol?) -> AuthenticationService {
|
||||||
AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
|
AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
|
||||||
encryptionKeyProvider: EncryptionKeyProvider(),
|
encryptionKeyProvider: EncryptionKeyProvider(),
|
||||||
classicAppManager: nil,
|
classicAppManager: classicAppManager,
|
||||||
clientFactory: AuthenticationClientFactoryMock(configuration: .init()),
|
clientFactory: AuthenticationClientFactoryMock(configuration: .init()),
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
appHooks: AppHooks())
|
appHooks: AppHooks())
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ protocol AuthenticationServiceProtocol: QRCodeLoginServiceProtocol {
|
|||||||
|
|
||||||
/// Resets the current configuration requiring `configure(for:flow:)` to be called again.
|
/// Resets the current configuration requiring `configure(for:flow:)` to be called again.
|
||||||
func reset()
|
func reset()
|
||||||
|
|
||||||
|
// MARK: - Classic App
|
||||||
|
|
||||||
|
/// Account details discovered from the Classic app that is used for automatic verification when the same account is authenticated.
|
||||||
|
var classicAppAccount: ClassicAppAccount? { get }
|
||||||
|
/// Populates the Classic app account's state by checking if the homeserver is supported and which secrets are available.
|
||||||
|
///
|
||||||
|
/// **Note:** This is no longer automatic purely for testing purposes. It needs to have been called before using ``classicAppAccount``.
|
||||||
|
func setupClassicAppAccountState() async
|
||||||
|
/// This can be called whenever the user has potentially updated their secrets in the Classic app.
|
||||||
|
func refreshClassicAppAccountState() async
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - OIDC
|
// MARK: - OIDC
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct ClassicAppAccount: Equatable, CustomStringConvertible {
|
struct ClassicAppAccount: Equatable, CustomStringConvertible {
|
||||||
let userID: String
|
let userID: String
|
||||||
@@ -24,6 +25,27 @@ struct ClassicAppAccount: Equatable, CustomStringConvertible {
|
|||||||
var description: String {
|
var description: String {
|
||||||
"ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))"
|
"ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AvailableSecrets { case complete, requiresBackup, unavailable }
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class State: Equatable {
|
||||||
|
static func == (lhs: State, rhs: State) -> Bool {
|
||||||
|
lhs.isServerSupported == rhs.isServerSupported && lhs.availableSecrets == rhs.availableSecrets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not the account's server is supported by Element X (or `nil` whilst determining support).
|
||||||
|
///
|
||||||
|
/// The account will be hidden when this value is `false`.
|
||||||
|
var isServerSupported: Bool?
|
||||||
|
/// Information about the secrets available from Element X (or `nil` whilst determining availability).
|
||||||
|
///
|
||||||
|
/// See ``AuthenticationService.refreshClassicAppAccountState`` for details about how
|
||||||
|
/// this property's value affects the authentication flow.
|
||||||
|
var availableSecrets: AvailableSecrets?
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = State()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: NSCoding Types
|
// MARK: NSCoding Types
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ import MatrixRustSDK
|
|||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol ClassicAppManagerProtocol {
|
protocol ClassicAppManagerProtocol {
|
||||||
|
/// Loads all of the accounts found in the Classic app's file store.
|
||||||
func loadAccounts() throws -> [ClassicAppAccount]
|
func loadAccounts() throws -> [ClassicAppAccount]
|
||||||
|
/// Determines which secrets will be available when loading the secrets bundle for a given account.
|
||||||
|
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets
|
||||||
|
/// Loads the secrets bundle for a given account.
|
||||||
|
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ClassicAppManagerError: Error {
|
enum ClassicAppManagerError: Error {
|
||||||
case invalidAppGroupIdentifier(String)
|
case invalidAppGroupIdentifier(String)
|
||||||
case missingAccountKeys
|
case missingAccountKeys
|
||||||
case missingCryptoStorePassphrase
|
case missingCryptoStorePassphrase
|
||||||
|
case missingKeyBackupVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads accounts from Element Classic's shared storage.
|
/// Reads accounts from Element Classic's shared storage.
|
||||||
@@ -45,7 +51,6 @@ final class ClassicAppManager: ClassicAppManagerProtocol {
|
|||||||
keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier)
|
keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads all of the active accounts from the Classic app.
|
|
||||||
func loadAccounts() throws -> [ClassicAppAccount] {
|
func loadAccounts() throws -> [ClassicAppAccount] {
|
||||||
// The account data is stored in the App Group container.
|
// The account data is stored in the App Group container.
|
||||||
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else {
|
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else {
|
||||||
@@ -69,4 +74,41 @@ final class ClassicAppManager: ClassicAppManagerProtocol {
|
|||||||
accountManager.loadAccounts()
|
accountManager.loadAccounts()
|
||||||
return accountManager.accounts
|
return accountManager.accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets {
|
||||||
|
switch try await databaseContainsSecretsBundle(databasePath: account.cryptoStoreURL.path(percentEncoded: false),
|
||||||
|
passphrase: account.cryptoStorePassphrase,
|
||||||
|
backupInfo: keyBackupVersion(for: account)) {
|
||||||
|
case .complete: .complete
|
||||||
|
case .none: .unavailable
|
||||||
|
case .withoutBackup, .unusedBackup: .requiresBackup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId {
|
||||||
|
guard let keyBackupVersion = try await keyBackupVersion(for: account) else {
|
||||||
|
throw ClassicAppManagerError.missingKeyBackupVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await SecretsBundleWithUserId.fromDatabase(databasePath: account.cryptoStoreURL.path(percentEncoded: false),
|
||||||
|
passphrase: account.cryptoStorePassphrase,
|
||||||
|
backupInfo: keyBackupVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the current key backup version from the homeserver. This is needed to determine whether
|
||||||
|
/// the backup key from the crypto store is for the backup currently being used by the account.
|
||||||
|
private func keyBackupVersion(for account: ClassicAppAccount) async throws -> String? {
|
||||||
|
let url = account.homeserverURL.appending(path: "_matrix/client/v3/room_keys/version")
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("Bearer \(account.accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode != 200 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,8 @@
|
|||||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
<key>baseBundleIdentifier</key>
|
<key>baseBundleIdentifier</key>
|
||||||
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
|
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>classicAppDeepLinkURL</key>
|
||||||
|
<string>$(CLASSIC_APP_DEEP_LINK_URL)</string>
|
||||||
<key>classicAppGroupIdentifier</key>
|
<key>classicAppGroupIdentifier</key>
|
||||||
<string>$(CLASSIC_APP_GROUP_IDENTIFIER)</string>
|
<string>$(CLASSIC_APP_GROUP_IDENTIFIER)</string>
|
||||||
<key>classicAppKeychainAccessGroupIdentifier</key>
|
<key>classicAppKeychainAccessGroupIdentifier</key>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ targets:
|
|||||||
classicAppGroupIdentifier: $(CLASSIC_APP_GROUP_IDENTIFIER)
|
classicAppGroupIdentifier: $(CLASSIC_APP_GROUP_IDENTIFIER)
|
||||||
classicAppKeychainServiceIdentifier: $(CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER)
|
classicAppKeychainServiceIdentifier: $(CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER)
|
||||||
classicAppKeychainAccessGroupIdentifier: $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
|
classicAppKeychainAccessGroupIdentifier: $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
|
||||||
|
classicAppDeepLinkURL: $(CLASSIC_APP_DEEP_LINK_URL)
|
||||||
productionAppName: $(PRODUCTION_APP_NAME)
|
productionAppName: $(PRODUCTION_APP_NAME)
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
NSUserActivityTypes: [
|
NSUserActivityTypes: [
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:19e12d5dbc56168733ce4956d8b420fe9d2ce72d404ce2ec56323985ab2c226a
|
||||||
|
size 145006
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0843207a3645299effecf77d86caa8cf99a801e253c9ca49f789c014965a4e3c
|
||||||
|
size 181159
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:08cf95b60f99486657ffc4b96c557f458c9c843bbe2d0265dbd4b40c3a232662
|
||||||
|
size 96042
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:79301b22af27679df686a607d6b1f8011b6ebea37f97040e900794372adaa218
|
||||||
|
size 141913
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6822abf96bfd69a5bfea7c9e3b32a632dccd1d1434e3e851eb65804a0d359440
|
||||||
|
size 144361
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3da597921389f7f4c271eb691945f13c09476454bc491161d28022adec4b31c0
|
||||||
|
size 179232
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:914849110d107a01c557f1d6289ace6eb5dc8b9831215cb486ae9401495b2e36
|
||||||
|
size 95480
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8c771e99c1848dcc57f952e2d8426da9adeb97cb97fa8797b3e15f7e137ee08f
|
||||||
|
size 140035
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:1d033dd37f0c28c175f1181404ea9855963ffe830394dabfbbaf4877f36e52a4
|
|
||||||
size 145715
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ffa8567fccd143f8dfe238c96d263717c99a6448953d3d10247b1012c751a956
|
|
||||||
size 181186
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c20b19a79a4287aca05cc58d51e09232861361a5d9f29606f8a7eb912e78d2ee
|
|
||||||
size 96938
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ef97e734216be0e6ef36d0f0106c71728ea5a0cf57ce2164ad09157953dc22e2
|
|
||||||
size 141919
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:01f374840a1098f1870230fa9784cf56b250858b7341d575643af4f178932e7c
|
||||||
|
size 645596
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:af206923909523cd2d61b7af9d9e66a333a688c758131550d37357a740ceccac
|
||||||
|
size 651937
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:35bbd58addd849a6e088928b15496347e3e05d5961f089485bbbdd2623ed42d0
|
||||||
|
size 432550
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2fb2d1117f8af713e510b37d136f9b9c73c2d6eb7fc00ab3726b1b2e84e2e208
|
||||||
|
size 440193
|
||||||
@@ -14,13 +14,14 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct AuthenticationServiceTests {
|
struct AuthenticationServiceTests {
|
||||||
var client: ClientSDKMock!
|
var client: ClientSDKMock!
|
||||||
|
var encryption: EncryptionSDKMock!
|
||||||
var userSessionStore: UserSessionStoreMock!
|
var userSessionStore: UserSessionStoreMock!
|
||||||
var encryptionKeyProvider: MockEncryptionKeyProvider!
|
var encryptionKeyProvider: MockEncryptionKeyProvider!
|
||||||
var service: AuthenticationService!
|
var service: AuthenticationService!
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
mutating func passwordLogin() async {
|
mutating func passwordLogin() async throws {
|
||||||
setup(serverAddress: "example.com")
|
try await setup(serverAddress: "example.com")
|
||||||
|
|
||||||
switch await service.configure(for: "example.com", flow: .login) {
|
switch await service.configure(for: "example.com", flow: .login) {
|
||||||
case .success:
|
case .success:
|
||||||
@@ -44,30 +45,20 @@ struct AuthenticationServiceTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
mutating func configureLoginWithOIDC() async {
|
mutating func configureLoginWithOIDC() async throws {
|
||||||
setup()
|
try await setup()
|
||||||
|
|
||||||
switch await service.configure(for: "matrix.org", flow: .login) {
|
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||||
case .success:
|
|
||||||
break
|
|
||||||
case .failure(let error):
|
|
||||||
Issue.record("Unexpected failure: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(service.flow == .login)
|
#expect(service.flow == .login)
|
||||||
#expect(service.homeserver.value == .mockMatrixDotOrg)
|
#expect(service.homeserver.value == .mockMatrixDotOrg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
mutating func configureRegisterWithOIDC() async {
|
mutating func configureRegisterWithOIDC() async throws {
|
||||||
setup()
|
try await setup()
|
||||||
|
|
||||||
switch await service.configure(for: "matrix.org", flow: .register) {
|
try await service.configure(for: "matrix.org", flow: .register).get()
|
||||||
case .success:
|
|
||||||
break
|
|
||||||
case .failure(let error):
|
|
||||||
Issue.record("Unexpected failure: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(service.flow == .register)
|
#expect(service.flow == .register)
|
||||||
#expect(service.homeserver.value == .mockMatrixDotOrg)
|
#expect(service.homeserver.value == .mockMatrixDotOrg)
|
||||||
@@ -75,37 +66,101 @@ struct AuthenticationServiceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@MainActor
|
@MainActor
|
||||||
mutating func configureRegisterNoSupport() async {
|
mutating func configureRegisterNoSupport() async throws {
|
||||||
let homeserverAddress = "example.com"
|
let homeserverAddress = "example.com"
|
||||||
setup(serverAddress: homeserverAddress)
|
try await setup(serverAddress: homeserverAddress)
|
||||||
|
|
||||||
switch await service.configure(for: homeserverAddress, flow: .register) {
|
try await #require(throws: AuthenticationServiceError.registrationNotSupported) {
|
||||||
case .success:
|
try await service.configure(for: homeserverAddress, flow: .register).get()
|
||||||
Issue.record("Configuration should have failed")
|
|
||||||
case .failure(let error):
|
|
||||||
#expect(error == .registrationNotSupported)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(service.flow == .login)
|
#expect(service.flow == .login)
|
||||||
#expect(service.homeserver.value == .init(address: "matrix.org", loginMode: .unknown))
|
#expect(service.homeserver.value == .init(address: "matrix.org", loginMode: .unknown))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MainActor
|
||||||
|
mutating func classicAppAccountSecretsBundleIsUsed() async throws {
|
||||||
|
// Given an authentication service with an Element Classic account for Alice.
|
||||||
|
try await setup(classicAppAccounts: [.mockAlice])
|
||||||
|
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||||
|
#expect(service.flow == .login)
|
||||||
|
#expect(service.classicAppAccount?.state.availableSecrets == .complete)
|
||||||
|
|
||||||
|
// When logging in as Alice.
|
||||||
|
_ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get()
|
||||||
|
#expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1)
|
||||||
|
|
||||||
|
// Then Alice's secrets from Element Classic should be imported.
|
||||||
|
#expect(encryption.importSecretsBundleSecretsBundleCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MainActor
|
||||||
|
mutating func classicAppAccountSecretsBundleIsIgnoredWhenUnavailable() async throws {
|
||||||
|
// Given an authentication service with an Element Classic account for Alice
|
||||||
|
// which isn't configured with any available secrets.
|
||||||
|
try await setup(classicAppAccounts: [.mockAlice], availableSecrets: .unavailable)
|
||||||
|
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||||
|
#expect(service.flow == .login)
|
||||||
|
#expect(service.classicAppAccount?.state.availableSecrets == .unavailable)
|
||||||
|
|
||||||
|
// When logging in as Alice.
|
||||||
|
_ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get()
|
||||||
|
#expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1)
|
||||||
|
|
||||||
|
// Then an attempt to import Alice's secrets from Element Classic must not be made.
|
||||||
|
#expect(!encryption.importSecretsBundleSecretsBundleCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@MainActor
|
||||||
|
mutating func classicAppAccountSecretsBundleIsIgnoredForDifferentUser() async throws {
|
||||||
|
// Given an authentication service with an Element Classic account for Dan.
|
||||||
|
try await setup(classicAppAccounts: [.mockDan])
|
||||||
|
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||||
|
#expect(service.flow == .login)
|
||||||
|
#expect(service.classicAppAccount?.state.availableSecrets == .complete)
|
||||||
|
|
||||||
|
// When logging in as Alice
|
||||||
|
_ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get()
|
||||||
|
#expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1)
|
||||||
|
|
||||||
|
// Then Dan's secrets from Element Calssic should not be imported into Alice's client.
|
||||||
|
#expect(!encryption.importSecretsBundleSecretsBundleCalled)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private mutating func setup(serverAddress: String = "matrix.org") {
|
private mutating func setup(serverAddress: String = "matrix.org",
|
||||||
|
classicAppAccounts: [ClassicAppAccount] = [],
|
||||||
|
availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async throws {
|
||||||
let configuration: AuthenticationClientFactoryMock.Configuration = .init()
|
let configuration: AuthenticationClientFactoryMock.Configuration = .init()
|
||||||
let clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
|
let clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
|
||||||
|
|
||||||
client = configuration.homeserverClients[serverAddress]
|
client = configuration.homeserverClients[serverAddress]
|
||||||
|
encryption = EncryptionSDKMock()
|
||||||
|
client.encryptionReturnValue = encryption
|
||||||
|
|
||||||
userSessionStore = UserSessionStoreMock(configuration: .init())
|
userSessionStore = UserSessionStoreMock(configuration: .init())
|
||||||
encryptionKeyProvider = MockEncryptionKeyProvider()
|
encryptionKeyProvider = MockEncryptionKeyProvider()
|
||||||
|
|
||||||
|
let classicAppManager = ClassicAppManagerMock(.init(accounts: classicAppAccounts,
|
||||||
|
availableSecrets: availableSecrets,
|
||||||
|
secretsBundle: .init(noHandle: .init())))
|
||||||
|
|
||||||
service = AuthenticationService(userSessionStore: userSessionStore,
|
service = AuthenticationService(userSessionStore: userSessionStore,
|
||||||
encryptionKeyProvider: encryptionKeyProvider,
|
encryptionKeyProvider: encryptionKeyProvider,
|
||||||
classicAppManager: nil,
|
classicAppManager: classicAppManager,
|
||||||
clientFactory: clientFactory,
|
clientFactory: clientFactory,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
appHooks: AppHooks())
|
appHooks: AppHooks())
|
||||||
|
|
||||||
|
if let classicAppAccount = service.classicAppAccount {
|
||||||
|
await service.setupClassicAppAccountState()
|
||||||
|
try #require(classicAppAccount.state.isServerSupported == true)
|
||||||
|
try #require(classicAppAccount.state.availableSecrets == availableSecrets)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import UIKit
|
|||||||
final class AuthenticationStartScreenViewModelTests {
|
final class AuthenticationStartScreenViewModelTests {
|
||||||
var clientFactory: AuthenticationClientFactoryMock!
|
var clientFactory: AuthenticationClientFactoryMock!
|
||||||
var client: ClientSDKMock!
|
var client: ClientSDKMock!
|
||||||
|
var classicAppManager: ClassicAppManagerMock?
|
||||||
|
var notificationCenter: NotificationCenter!
|
||||||
var appSettings: AppSettings!
|
var appSettings: AppSettings!
|
||||||
var authenticationService: AuthenticationServiceProtocol!
|
var authenticationService: AuthenticationServiceProtocol!
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ final class AuthenticationStartScreenViewModelTests {
|
|||||||
@Test
|
@Test
|
||||||
func initialState() async throws {
|
func initialState() async throws {
|
||||||
// Given a view model that has no provisioning parameters.
|
// Given a view model that has no provisioning parameters.
|
||||||
setupViewModel()
|
await setupViewModel()
|
||||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ final class AuthenticationStartScreenViewModelTests {
|
|||||||
@Test
|
@Test
|
||||||
func provisionedOIDCState() async throws {
|
func provisionedOIDCState() async throws {
|
||||||
// Given a view model that has been provisioned with a server that supports OIDC.
|
// Given a view model that has been provisioned with a server that supports OIDC.
|
||||||
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
|
await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
|
||||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ final class AuthenticationStartScreenViewModelTests {
|
|||||||
@Test
|
@Test
|
||||||
func provisionedPasswordState() async throws {
|
func provisionedPasswordState() async throws {
|
||||||
// Given a view model that has been provisioned with a server that does not support OIDC.
|
// Given a view model that has been provisioned with a server that does not support OIDC.
|
||||||
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false)
|
await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false)
|
||||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ final class AuthenticationStartScreenViewModelTests {
|
|||||||
func singleProviderOIDCState() async throws {
|
func singleProviderOIDCState() async throws {
|
||||||
// Given a view model that for an app that only allows the use of a single provider that supports OIDC.
|
// Given a view model that for an app that only allows the use of a single provider that supports OIDC.
|
||||||
setAllowedAccountProviders(["company.com"])
|
setAllowedAccountProviders(["company.com"])
|
||||||
setupViewModel()
|
await setupViewModel()
|
||||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ final class AuthenticationStartScreenViewModelTests {
|
|||||||
func singleProviderPasswordState() async throws {
|
func singleProviderPasswordState() async throws {
|
||||||
// Given a view model that for an app that only allows the use of a single provider that does not support OIDC.
|
// Given a view model that for an app that only allows the use of a single provider that does not support OIDC.
|
||||||
setAllowedAccountProviders(["company.com"])
|
setAllowedAccountProviders(["company.com"])
|
||||||
setupViewModel(supportsOIDC: false)
|
await setupViewModel(supportsOIDC: false)
|
||||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||||
|
|
||||||
@@ -139,34 +141,202 @@ final class AuthenticationStartScreenViewModelTests {
|
|||||||
#expect(authenticationService.homeserver.value.loginMode == .password)
|
#expect(authenticationService.homeserver.value.loginMode == .password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Classic App Account
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func classicAppAccount() async throws {
|
||||||
|
// Given a view model with a Classic app account whose server name resolves successfully.
|
||||||
|
let classicAppAccount = makeClassicAppAccount()
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount)
|
||||||
|
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
|
||||||
|
Issue.record("Expected classicAppMode to be .welcomeBack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(account == classicAppAccount)
|
||||||
|
|
||||||
|
// When continuing with the Classic app account the authentication service should be used and the screen
|
||||||
|
// should request to continue the flow without any server selection needed.
|
||||||
|
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
|
||||||
|
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
|
||||||
|
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "company.com")
|
||||||
|
#expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false))
|
||||||
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func classicAppAccountWithoutWellKnown() async throws {
|
||||||
|
// Given a view model where the Classic app account's server name has no well-known file.
|
||||||
|
let classicAppAccount = makeClassicAppAccount(serverName: "unknown-server.org",
|
||||||
|
homeserverURL: "https://matrix.company.com")
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount)
|
||||||
|
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
|
||||||
|
Issue.record("Expected classicAppMode to be .welcomeBack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(account == classicAppAccount)
|
||||||
|
|
||||||
|
// When continuing with the Classic app account the authentication service should be used with the direct homeserver URL
|
||||||
|
// and the screen should request to continue the flow without any server selection needed.
|
||||||
|
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
|
||||||
|
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
|
||||||
|
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "https://matrix.company.com")
|
||||||
|
#expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false))
|
||||||
|
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func classicAppAccountOnUnsupportedServer() async {
|
||||||
|
// Given a view model with a Classic app account whose server supports neither OIDC nor password login.
|
||||||
|
let classicAppAccount = makeClassicAppAccount()
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount, supportsOIDC: false, supportsPasswordLogin: false)
|
||||||
|
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
|
||||||
|
Issue.record("Expected classicAppMode to be .welcomeBack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(account == classicAppAccount)
|
||||||
|
|
||||||
|
// Then the Classic app account should indicate that it isn't supported (so the view falls back to the standard content).
|
||||||
|
#expect(account.state.isServerSupported == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func classicAppAccountWithProvisioningLink() async {
|
||||||
|
// Given a view model that has been provisioned with a provisioning link (and a classic account exists).
|
||||||
|
let classicAppAccount = makeClassicAppAccount()
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount,
|
||||||
|
provisioningParameters: .init(accountProvider: "company.com", loginHint: nil))
|
||||||
|
|
||||||
|
// Then the Classic app account should not be shown — provisioning takes precedence.
|
||||||
|
#expect(context.viewState.classicAppMode == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func singleProviderWithMatchingClassicAppAccount() async {
|
||||||
|
// Given a view model for an app that only allows a single provider that matches the Classic account's server.
|
||||||
|
let classicAppAccount = makeClassicAppAccount(serverName: "company.com",
|
||||||
|
homeserverURL: "https://matrix.company.com")
|
||||||
|
setAllowedAccountProviders(["company.com"])
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount)
|
||||||
|
|
||||||
|
// Then the Classic app account should be shown as a welcome-back option.
|
||||||
|
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
|
||||||
|
Issue.record("Expected classicAppMode to be .welcomeBack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(account == classicAppAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func singleProviderWithDisallowedClassicAppAccount() async {
|
||||||
|
// Given a view model for an app that only allows a single provider that does NOT match the Classic account's server.
|
||||||
|
let classicAppAccount = makeClassicAppAccount(serverName: "other-server.org",
|
||||||
|
homeserverURL: "https://matrix.other-server.org")
|
||||||
|
setAllowedAccountProviders(["company.com"])
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount)
|
||||||
|
|
||||||
|
// Then the Classic app account should not be shown since the server is not in the allowed providers.
|
||||||
|
#expect(context.viewState.classicAppMode == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func classicAppAccountRequiresBackup() async throws {
|
||||||
|
// Given a view model with a Classic app account that requires backup before signing in.
|
||||||
|
let classicAppAccount = makeClassicAppAccount()
|
||||||
|
await setupViewModel(classicAppAccount: classicAppAccount, availableSecrets: .requiresBackup)
|
||||||
|
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
|
||||||
|
Issue.record("Expected classicAppMode to be .welcomeBack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(account.state.availableSecrets == .requiresBackup)
|
||||||
|
|
||||||
|
// When continuing with the Classic account while backup is required.
|
||||||
|
var deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { $0 }
|
||||||
|
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||||
|
|
||||||
|
// Then the backup instructions should be shown.
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// When the user completes the backup in the Classic app and the app returns to the foreground.
|
||||||
|
classicAppManager?.availableSecretsForReturnValue = .complete
|
||||||
|
deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { !$0 }
|
||||||
|
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
|
||||||
|
// Then the backup instructions sheet should be dismissed.
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// When the user continues with the Classic account again.
|
||||||
|
let deferredAction = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
|
||||||
|
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||||
|
|
||||||
|
// Then the flow should continue the login process.
|
||||||
|
try await deferredAction.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func setupViewModel(provisioningParameters: AccountProvisioningParameters? = nil, supportsOIDC: Bool = true) {
|
private func setupViewModel(classicAppAccount: ClassicAppAccount? = nil,
|
||||||
|
provisioningParameters: AccountProvisioningParameters? = nil,
|
||||||
|
supportsOIDC: Bool = true,
|
||||||
|
supportsPasswordLogin: Bool = true,
|
||||||
|
availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async {
|
||||||
// Manually create a configuration as the default homeserver address setting is immutable.
|
// Manually create a configuration as the default homeserver address setting is immutable.
|
||||||
client = ClientSDKMock(configuration: .init(oidcLoginURL: supportsOIDC ? "https://account.company.com/authorize" : nil,
|
client = ClientSDKMock(configuration: .init(oidcLoginURL: supportsOIDC ? "https://account.company.com/authorize" : nil,
|
||||||
supportsOIDCCreatePrompt: false,
|
supportsOIDCCreatePrompt: false,
|
||||||
supportsPasswordLogin: true))
|
supportsPasswordLogin: supportsPasswordLogin))
|
||||||
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["company.com": client])
|
// Map both the server name and the homeserver URL so fallback lookups work.
|
||||||
|
let homeserverClients: [String: ClientSDKMock] = ["company.com": client,
|
||||||
|
"https://matrix.company.com": client]
|
||||||
|
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: homeserverClients)
|
||||||
|
|
||||||
|
if let classicAppAccount {
|
||||||
|
classicAppManager = ClassicAppManagerMock(.init(accounts: [classicAppAccount], availableSecrets: availableSecrets))
|
||||||
|
} else {
|
||||||
|
classicAppManager = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationCenter = NotificationCenter()
|
||||||
|
|
||||||
clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
|
clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
|
||||||
authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
|
authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
|
||||||
encryptionKeyProvider: EncryptionKeyProvider(),
|
encryptionKeyProvider: EncryptionKeyProvider(),
|
||||||
classicAppManager: nil,
|
classicAppManager: classicAppManager,
|
||||||
clientFactory: clientFactory,
|
clientFactory: clientFactory,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
appHooks: AppHooks())
|
appHooks: AppHooks())
|
||||||
|
|
||||||
|
await authenticationService.setupClassicAppAccountState()
|
||||||
|
|
||||||
viewModel = AuthenticationStartScreenViewModel(authenticationService: authenticationService,
|
viewModel = AuthenticationStartScreenViewModel(authenticationService: authenticationService,
|
||||||
provisioningParameters: provisioningParameters,
|
provisioningParameters: provisioningParameters,
|
||||||
isBugReportServiceEnabled: true,
|
isBugReportServiceEnabled: true,
|
||||||
|
appMediator: AppMediatorMock(),
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
userIndicatorController: UserIndicatorControllerMock())
|
userIndicatorController: UserIndicatorControllerMock())
|
||||||
|
|
||||||
// Add a fake window in order for the OIDC flow to continue
|
// Add a fake window in order for the OIDC flow to continue
|
||||||
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
|
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeClassicAppAccount(serverName: String = "company.com",
|
||||||
|
homeserverURL: URL = "https://matrix.company.com") -> ClassicAppAccount {
|
||||||
|
ClassicAppAccount(userID: "@user:\(serverName)",
|
||||||
|
displayName: "Classic User",
|
||||||
|
avatarURL: nil,
|
||||||
|
serverName: serverName,
|
||||||
|
homeserverURL: homeserverURL,
|
||||||
|
cryptoStoreURL: "file:///tmp/crypto-store",
|
||||||
|
cryptoStorePassphrase: "passphrase",
|
||||||
|
accessToken: "accessToken")
|
||||||
|
}
|
||||||
|
|
||||||
private func setAllowedAccountProviders(_ providers: [String]) {
|
private func setAllowedAccountProviders(_ providers: [String]) {
|
||||||
appSettings.override(accountProviders: providers,
|
appSettings.override(accountProviders: providers,
|
||||||
allowOtherAccountProviders: false,
|
allowOtherAccountProviders: false,
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ settings:
|
|||||||
CLASSIC_APP_GROUP_IDENTIFIER: group.im.vector
|
CLASSIC_APP_GROUP_IDENTIFIER: group.im.vector
|
||||||
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: im.vector.app.encryption-manager-service
|
CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: im.vector.app.encryption-manager-service
|
||||||
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"
|
CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).im.vector.app.keychain.shared"
|
||||||
|
CLASSIC_APP_DEEP_LINK_URL: "element://open"
|
||||||
|
|||||||
Reference in New Issue
Block a user