mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +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:
+2
@@ -13,6 +13,7 @@ struct AuthenticationStartScreenParameters {
|
||||
let authenticationService: AuthenticationServiceProtocol
|
||||
let provisioningParameters: AccountProvisioningParameters?
|
||||
let isBugReportServiceEnabled: Bool
|
||||
let appMediator: AppMediatorProtocol
|
||||
let appSettings: AppSettings
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
@@ -41,6 +42,7 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
|
||||
viewModel = AuthenticationStartScreenViewModel(authenticationService: parameters.authenticationService,
|
||||
provisioningParameters: parameters.provisioningParameters,
|
||||
isBugReportServiceEnabled: parameters.isBugReportServiceEnabled,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: parameters.appSettings,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
|
||||
+4
@@ -26,6 +26,9 @@ struct AuthenticationStartScreenViewState: BindableState {
|
||||
let showCreateAccountButton: Bool
|
||||
let showQRCodeLoginButton: Bool
|
||||
|
||||
enum ClassicAppMode { case welcomeBack(ClassicAppAccount), otherOptions(ClassicAppAccount) }
|
||||
var classicAppMode: ClassicAppMode?
|
||||
|
||||
let hideBrandChrome: Bool
|
||||
|
||||
var bindings = AuthenticationStartScreenViewStateBindings()
|
||||
@@ -62,4 +65,5 @@ enum AuthenticationStartScreenViewAction {
|
||||
case continueWithClassic(ClassicAppAccount)
|
||||
case otherOptions(ClassicAppAccount)
|
||||
case closeOtherOptions(ClassicAppAccount)
|
||||
case openClassicApp
|
||||
}
|
||||
|
||||
+63
-9
@@ -14,6 +14,7 @@ typealias AuthenticationStartScreenViewModelType = StateStoreViewModelV2<Authent
|
||||
class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType, AuthenticationStartScreenViewModelProtocol {
|
||||
private let authenticationService: AuthenticationServiceProtocol
|
||||
private let provisioningParameters: AccountProvisioningParameters?
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
@@ -28,16 +29,21 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
||||
init(authenticationService: AuthenticationServiceProtocol,
|
||||
provisioningParameters: AccountProvisioningParameters?,
|
||||
isBugReportServiceEnabled: Bool,
|
||||
appMediator: AppMediatorProtocol,
|
||||
appSettings: AppSettings,
|
||||
mediaProvider: MediaProviderProtocol?,
|
||||
notificationCenter: NotificationCenter = .default,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.authenticationService = authenticationService
|
||||
self.provisioningParameters = provisioningParameters
|
||||
self.appMediator = appMediator
|
||||
self.appSettings = appSettings
|
||||
self.userIndicatorController = userIndicatorController
|
||||
canReportProblem = isBugReportServiceEnabled
|
||||
|
||||
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
|
||||
let classicAppAccountProvider = authenticationService.classicAppAccount?.serverName
|
||||
let isClassicAppAccountAllowed = classicAppAccountProvider.map { appSettings.accountProviders.contains($0) } ?? false
|
||||
|
||||
let initialViewState = if !appSettings.allowOtherAccountProviders {
|
||||
// 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,
|
||||
showCreateAccountButton: false,
|
||||
showQRCodeLoginButton: isQRCodeScanningSupported,
|
||||
classicAppMode: isClassicAppAccountAllowed ? authenticationService.classicAppAccount.map { .welcomeBack($0) } : nil,
|
||||
hideBrandChrome: appSettings.hideBrandChrome)
|
||||
} else if let provisioningParameters {
|
||||
// We only show the "Sign in to …" button when using a provisioning link.
|
||||
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
|
||||
showCreateAccountButton: false,
|
||||
showQRCodeLoginButton: false,
|
||||
classicAppMode: nil,
|
||||
hideBrandChrome: appSettings.hideBrandChrome)
|
||||
} else {
|
||||
// The default configuration.
|
||||
AuthenticationStartScreenViewState(serverName: nil,
|
||||
showCreateAccountButton: appSettings.showCreateAccountButton,
|
||||
showQRCodeLoginButton: isQRCodeScanningSupported,
|
||||
classicAppMode: authenticationService.classicAppAccount.map { .welcomeBack($0) },
|
||||
hideBrandChrome: appSettings.hideBrandChrome)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -78,29 +93,51 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
||||
if canReportProblem {
|
||||
actionsSubject.send(.reportProblem)
|
||||
}
|
||||
case .continueWithClassic, .otherOptions, .closeOtherOptions:
|
||||
break // To follow.
|
||||
case .continueWithClassic(let account):
|
||||
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
|
||||
|
||||
private func login() async {
|
||||
if let serverName = state.serverName {
|
||||
private func login(classicAppAccount: ClassicAppAccount? = nil) async {
|
||||
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)
|
||||
} else {
|
||||
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()
|
||||
defer { stopLoading() }
|
||||
|
||||
guard case .success = await authenticationService.configure(for: accountProvider, flow: .login) else {
|
||||
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
|
||||
displayError()
|
||||
return
|
||||
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.
|
||||
// Element Classic accounts aren't shown for unsupported servers either, so nothing to do here.
|
||||
displayError()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
|
||||
@@ -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 func startLoading() {
|
||||
|
||||
+45
-8
@@ -13,6 +13,10 @@ struct AuthenticationClassicAppAccountView: View {
|
||||
|
||||
let classicAppAccount: ClassicAppAccount
|
||||
|
||||
var isLoadingAccount: Bool {
|
||||
classicAppAccount.state.isServerSupported == nil || classicAppAccount.state.availableSecrets == nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog(topPadding: 25, background: .gradient) {
|
||||
VStack(spacing: 38) {
|
||||
@@ -28,6 +32,11 @@ struct AuthenticationClassicAppAccountView: View {
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert(item: $context.alertInfo)
|
||||
.sheet(isPresented: $context.showClassicAppBackupInstructions) {
|
||||
AuthenticationClassicAppBackupInstructionsView(classicAppAccount: classicAppAccount) {
|
||||
context.send(viewAction: .openClassicApp)
|
||||
}
|
||||
}
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
@@ -73,15 +82,30 @@ struct AuthenticationClassicAppAccountView: View {
|
||||
|
||||
var buttons: some View {
|
||||
VStack(spacing: 16) {
|
||||
Button(L10n.actionContinue) {
|
||||
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||
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) {
|
||||
context.send(viewAction: .continueWithClassic(classicAppAccount))
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
|
||||
Button(L10n.commonOtherOptions) {
|
||||
context.send(viewAction: .otherOptions(classicAppAccount))
|
||||
}
|
||||
.buttonStyle(.compound(.secondary))
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
|
||||
Button(L10n.commonOtherOptions) {
|
||||
context.send(viewAction: .otherOptions(classicAppAccount))
|
||||
}
|
||||
.buttonStyle(.compound(.secondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,17 +126,30 @@ private extension ClassicAppAccount {
|
||||
|
||||
struct AuthenticationClassicAppAccountView_Previews: PreviewProvider { // Not Testable – snapshots generated by main screen.
|
||||
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 {
|
||||
ElementNavigationStack {
|
||||
AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: classicAppAccount)
|
||||
}
|
||||
.previewDisplayName("Ready")
|
||||
|
||||
ElementNavigationStack {
|
||||
AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: .mockDan)
|
||||
}
|
||||
.previewDisplayName("Loading")
|
||||
}
|
||||
|
||||
static func makeViewModel() -> AuthenticationStartScreenViewModel {
|
||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
||||
provisioningParameters: nil,
|
||||
isBugReportServiceEnabled: false,
|
||||
appMediator: AppMediatorMock(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
+32
-15
@@ -9,10 +9,19 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationClassicAppBackupInstructionsView: View {
|
||||
let context: AuthenticationStartScreenViewModel.Context
|
||||
|
||||
@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 {
|
||||
ElementNavigationStack {
|
||||
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
||||
@@ -32,6 +41,7 @@ struct AuthenticationClassicAppBackupInstructionsView: View {
|
||||
TitleAndIcon(title: L10n.screenMissingKeyBackupTitle(InfoPlistReader.main.bundleDisplayName),
|
||||
icon: \.keySolid,
|
||||
iconStyle: .default)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
SFNumberedListView(items: [
|
||||
AttributedString(L10n.screenMissingKeyBackupStep1),
|
||||
@@ -44,10 +54,18 @@ struct AuthenticationClassicAppBackupInstructionsView: View {
|
||||
}
|
||||
|
||||
var buttons: some View {
|
||||
Button(L10n.screenMissingKeyBackupOpenElementClassic) {
|
||||
UIApplication.shared.open("element://open")
|
||||
Button(action: openClassicAppAction) {
|
||||
Label {
|
||||
Text(buttonTitle)
|
||||
} icon: {
|
||||
if isRefreshingSecrets {
|
||||
ProgressView()
|
||||
.tint(.compound.iconOnSolidPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
.disabled(isRefreshingSecrets)
|
||||
}
|
||||
|
||||
var toolbar: some ToolbarContent {
|
||||
@@ -58,18 +76,17 @@ struct AuthenticationClassicAppBackupInstructionsView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
AuthenticationClassicAppBackupInstructionsView(context: viewModel.context)
|
||||
}
|
||||
|
||||
static func makeViewModel() -> AuthenticationStartScreenViewModel {
|
||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
||||
provisioningParameters: nil,
|
||||
isBugReportServiceEnabled: false,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
AuthenticationClassicAppBackupInstructionsView(classicAppAccount: loadedAccount) { }
|
||||
.previewDisplayName("Initial")
|
||||
|
||||
AuthenticationClassicAppBackupInstructionsView(classicAppAccount: .mockAlice) { }
|
||||
.previewDisplayName("Refreshing")
|
||||
}
|
||||
}
|
||||
|
||||
+41
-11
@@ -16,6 +16,15 @@ struct AuthenticationStartScreen: View {
|
||||
@Bindable var context: AuthenticationStartScreenViewModel.Context
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -41,14 +50,12 @@ struct AuthenticationStartScreen: View {
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.background {
|
||||
AuthenticationStartScreenBackgroundImage()
|
||||
}
|
||||
.navigationBarHidden(context.viewState.classicAppMode == nil)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
.sheet(isPresented: $context.showClassicAppBackupInstructions) {
|
||||
AuthenticationClassicAppBackupInstructionsView(context: context)
|
||||
}
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
context.send(viewAction: .updateWindow(window))
|
||||
}
|
||||
@@ -132,6 +139,17 @@ struct AuthenticationStartScreen: View {
|
||||
let shortVersionString = ProcessInfo.isRunningTests ? "0.0.0" : InfoPlistReader.main.bundleShortVersionString
|
||||
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
|
||||
@@ -139,20 +157,32 @@ struct AuthenticationStartScreen: View {
|
||||
struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
static let provisionedViewModel = makeViewModel(provisionedServerName: "example.com")
|
||||
static let classicAppViewModel = makeViewModel(hasClassicAppAccount: true)
|
||||
|
||||
static var previews: some View {
|
||||
AuthenticationStartScreen(context: viewModel.context)
|
||||
.previewDisplayName("Default")
|
||||
AuthenticationStartScreen(context: provisionedViewModel.context)
|
||||
.previewDisplayName("Provisioned")
|
||||
|
||||
ElementNavigationStack {
|
||||
AuthenticationStartScreen(context: classicAppViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Classic App")
|
||||
}
|
||||
|
||||
static func makeViewModel(provisionedServerName: String? = nil) -> AuthenticationStartScreenViewModel {
|
||||
AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock,
|
||||
provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) },
|
||||
isBugReportServiceEnabled: true,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
static func makeViewModel(provisionedServerName: String? = nil, hasClassicAppAccount: Bool = false) -> AuthenticationStartScreenViewModel {
|
||||
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) },
|
||||
isBugReportServiceEnabled: true,
|
||||
appMediator: AppMediatorMock(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user