* Expose logout method from SDK

* Use logout from SDK, refactor logging out states

* Add some strings

* Introduce `initialDisplayName` on `UIDevice`

* Implement soft logout screen

* Add `softLogout` test screen identifier

* Add new fields into the client proxy and implement new delegate methods

* Add new fields into the user session and observe new client callbacks

* Implement updated login method

* Add remote logout state and event into the state machine

* Implement refreshing restore token on `UserSessionStore`

* Update app coordinator with new states and display soft logout screen when appropriate

* Add reference screenshots for soft logout screen

* Disable auto requesting photos access on screenshot detection

* Make initial device name optional, generate project file

* Add changelog

* Use logout from SDK, refactor logging out states

* Implement soft logout screen

* Implement updated login method

* Make initial device name optional, generate project file

* Fix renamed event

* Fix logout race

* Remove redundant strings

* Reuse existing strings

* Confirm clear all data

* Expose logout method from SDK

* Use logout from SDK, refactor logging out states

* Add some strings

* Introduce `initialDisplayName` on `UIDevice`

* Implement soft logout screen

* Add `softLogout` test screen identifier

* Add new fields into the client proxy and implement new delegate methods

* Add new fields into the user session and observe new client callbacks

* Implement updated login method

* Add remote logout state and event into the state machine

* Implement refreshing restore token on `UserSessionStore`

* Update app coordinator with new states and display soft logout screen when appropriate

* Add reference screenshots for soft logout screen

* Disable auto requesting photos access on screenshot detection

* Make initial device name optional, generate project file

* Add changelog

* Use logout from SDK, refactor logging out states

* Implement soft logout screen

* Implement updated login method

* Make initial device name optional, generate project file

* Fix renamed event

* Fix logout race

* Remove redundant strings

* Reuse existing strings

* Confirm clear all data

* Comment out new apis for now

* Make the PR compile
This commit is contained in:
ismailgulek
2022-09-15 12:41:37 +03:00
committed by GitHub
parent 8967891e65
commit bf3c7e900f
37 changed files with 1237 additions and 63 deletions
+147 -29
View File
@@ -31,7 +31,7 @@ struct ServiceLocator {
let userIndicatorPresenter: UserIndicatorTypePresenter
}
class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
class AppCoordinator: Coordinator {
private let window: UIWindow
private let stateMachine: AppCoordinatorStateMachine
@@ -43,7 +43,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private let userSessionStore: UserSessionStoreProtocol
private var userSession: UserSessionProtocol!
private var userSession: UserSessionProtocol! {
didSet {
deobserveUserSessionChanges()
if let userSession = userSession, !userSession.isSoftLogout {
observeUserSessionChanges()
}
}
}
private let memberDetailProviderManager: MemberDetailProviderManager
@@ -53,6 +60,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private var loadingIndicator: UserIndicator?
private var statusIndicator: UserIndicator?
private var cancellables = Set<AnyCancellable>()
var childCoordinators: [Coordinator] = []
@@ -96,15 +105,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
window.makeKeyAndVisible()
stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication)
}
// MARK: - AuthenticationCoordinatorDelegate
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession
remove(childCoordinator: authenticationCoordinator)
stateMachine.processEvent(.succeededSigningIn)
}
// MARK: - Private
private func setupLogging() {
@@ -147,6 +148,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
case (.restoringSession, .failedRestoringSession, .signedOut):
self.hideLoadingIndicator()
self.showLoginErrorToast()
self.startAuthentication()
case (.restoringSession, .succeededRestoringSession, .homeScreen):
self.hideLoadingIndicator()
self.presentHomeScreen()
@@ -155,13 +157,20 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
self.presentRoomWithIdentifier(roomId)
case(.roomScreen, .dismissedRoomScreen, .homeScreen):
self.tearDownDismissedRoomScreen()
case (_, .attemptSignOut, .signingOut):
self.userSessionStore.logout(userSession: self.userSession)
self.stateMachine.processEvent(.succeededSigningOut)
case (.signingOut, .succeededSigningOut, .signedOut):
case (_, .signOut, .signingOut):
self.showLoadingIndicator()
self.tearDownUserSession()
case (.signingOut, .failedSigningOut, _):
self.showLogoutErrorToast()
case (.signingOut, .completedSigningOut, .signedOut):
self.presentSplashScreen()
self.hideLoadingIndicator()
case (_, .remoteSignOut(let isSoft), .remoteSigningOut):
self.showLoadingIndicator()
self.tearDownUserSession(isSoftLogout: isSoft)
case (.remoteSigningOut(let isSoft), .completedSigningOut, .signedOut):
self.presentSplashScreen(isSoftLogout: isSoft)
self.hideLoadingIndicator()
case (.homeScreen, .showSettingsScreen, .settingsScreen):
self.presentSettingsScreen()
@@ -190,7 +199,11 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
switch await userSessionStore.restoreUserSession() {
case .success(let userSession):
self.userSession = userSession
stateMachine.processEvent(.succeededRestoringSession)
if userSession.isSoftLogout {
stateMachine.processEvent(.remoteSignOut(isSoft: true))
} else {
stateMachine.processEvent(.succeededRestoringSession)
}
case .failure:
MXLog.error("Failed to restore an existing session.")
stateMachine.processEvent(.failedRestoringSession)
@@ -207,17 +220,80 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
add(childCoordinator: coordinator)
coordinator.start()
}
private func startAuthenticationSoftLogout() {
Task {
var displayName = ""
if case .success(let name) = await userSession.clientProxy.loadUserDisplayName() {
displayName = name
}
let credentials = SoftLogoutCredentials(userId: userSession.userID,
homeserverName: userSession.homeserver,
userDisplayName: displayName,
deviceId: userSession.deviceId)
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
_ = await authenticationService.configure(for: userSession.homeserver)
let parameters = SoftLogoutCoordinatorParameters(authenticationService: authenticationService,
credentials: credentials,
keyBackupNeeded: false)
let coordinator = SoftLogoutCoordinator(parameters: parameters)
coordinator.callback = { result in
switch result {
case .signedIn(let session):
self.userSession = session
self.remove(childCoordinator: coordinator)
self.stateMachine.processEvent(.succeededSigningIn)
case .clearAllData:
self.confirmClearAllData {
// clear user data
self.userSessionStore.logout(userSession: self.userSession)
self.userSession = nil
self.remove(childCoordinator: coordinator)
self.startAuthentication()
}
}
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.setRootModule(coordinator)
}
}
private func tearDownUserSession() {
private func tearDownUserSession(isSoftLogout: Bool = false) {
Task {
deobserveUserSessionChanges()
if !isSoftLogout {
// first log out from the server
_ = await userSession.clientProxy.logout()
// regardless of the result, clear user data
userSessionStore.logout(userSession: userSession)
userSession = nil
}
// complete logging out
stateMachine.processEvent(.completedSigningOut)
}
}
private func presentSplashScreen(isSoftLogout: Bool = false) {
if let presentedCoordinator = childCoordinators.first {
remove(childCoordinator: presentedCoordinator)
}
userSession = nil
mainNavigationController.setViewControllers([splashViewController], animated: false)
startAuthentication()
if isSoftLogout {
startAuthenticationSoftLogout()
} else {
startAuthentication()
}
}
private func presentHomeScreen() {
@@ -250,6 +326,29 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
showCrashPopup()
}
}
private func observeUserSessionChanges() {
userSession.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .didReceiveAuthError(let isSoftLogout):
self.stateMachine.processEvent(.remoteSignOut(isSoft: isSoftLogout))
case .updateRestoreTokenNeeded:
if let userSession = self.userSession {
_ = self.userSessionStore.refreshRestoreToken(for: userSession)
}
default:
break
}
}.store(in: &cancellables)
}
private func deobserveUserSessionChanges() {
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
}
// MARK: Rooms
@@ -305,7 +404,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
guard let self = self else { return }
switch action {
case .logout:
self.stateMachine.processEvent(.attemptSignOut)
self.stateMachine.processEvent(.signOut)
}
}
@@ -346,7 +445,20 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel))
alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { [weak self] _ in
self?.stateMachine.processEvent(.attemptSignOut)
self?.stateMachine.processEvent(.signOut)
})
navigationRouter.present(alert, animated: true)
}
/// Shows a confirmation to clear all data, and proceeds to do so if the user confirms.
private func confirmClearAllData(_ confirmed: @escaping () -> Void) {
let alert = UIAlertController(title: ElementL10n.softLogoutClearDataDialogTitle,
message: ElementL10n.softLogoutClearDataDialogContent,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { _ in
confirmed()
})
navigationRouter.present(alert, animated: true)
@@ -444,8 +556,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private func showLoginErrorToast() {
statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging in"))
}
private func showLogoutErrorToast() {
statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging out"))
}
// MARK: - AuthenticationCoordinatorDelegate
extension AppCoordinator: AuthenticationCoordinatorDelegate {
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession
remove(childCoordinator: authenticationCoordinator)
stateMachine.processEvent(.succeededSigningIn)
}
}
@@ -41,6 +41,9 @@ class AppCoordinatorStateMachine {
/// Processing a sign out request
case signingOut
/// Processing a remote sign out
case remoteSigningOut(isSoft: Bool)
}
/// Events that can be triggered on the AppCoordinator state machine
@@ -58,11 +61,11 @@ class AppCoordinatorStateMachine {
case failedRestoringSession
/// Request sign out
case attemptSignOut
/// Signing out succeeded
case succeededSigningOut
/// Signing out failed
case failedSigningOut
case signOut
/// Remote sign out.
case remoteSignOut(isSoft: Bool)
/// Signing out completed
case completedSigningOut
/// Request presentation for a particular room
/// - Parameter roomId:the room identifier
@@ -92,10 +95,8 @@ class AppCoordinatorStateMachine {
machine.addRoutes(event: .succeededRestoringSession, transitions: [.restoringSession => .homeScreen])
machine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])
machine.addRoutes(event: .attemptSignOut, transitions: [.any => .signingOut])
machine.addRoutes(event: .succeededSigningOut, transitions: [.signingOut => .signedOut])
machine.addRoutes(event: .failedSigningOut, transitions: [.signingOut => .settingsScreen])
machine.addRoutes(event: .signOut, transitions: [.any => .signingOut])
machine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut])
machine.addRoutes(event: .showSettingsScreen, transitions: [.homeScreen => .settingsScreen])
machine.addRoutes(event: .dismissedSettingsScreen, transitions: [.settingsScreen => .homeScreen])
@@ -110,6 +111,10 @@ class AppCoordinatorStateMachine {
return .roomScreen(roomId: roomId)
case (.dismissedRoomScreen, .roomScreen):
return .homeScreen
case (.remoteSignOut(let isSoft), _):
return .remoteSigningOut(isSoft: isSoft)
case (.completedSigningOut, .remoteSigningOut):
return .signedOut
default:
return nil
}
@@ -22,6 +22,10 @@ extension ElementL10n {
public static let authenticationServerInfoMatrixDescription = ElementL10n.tr("Untranslated", "authentication_server_info_matrix_description")
/// Choose your server to store your data
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
/// Mobile
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
/// Tablet
public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device")
/// Failed creating the permalink
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure")
/// Replying to %@
@@ -0,0 +1,29 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
extension UIDevice {
/// Returns if the device is a Phone
var isPhone: Bool {
userInterfaceIdiom == .phone
}
var initialDisplayName: String {
let string = isPhone ? ElementL10n.loginMobileDevice : ElementL10n.loginTabletDevice
return "X \(string)"
}
}
@@ -165,7 +165,10 @@ final class LoginCoordinator: Coordinator, Presentable {
startLoading(isInteractionBlocking: true)
Task {
switch await authenticationService.login(username: username, password: password) {
switch await authenticationService.login(username: username,
password: password,
initialDeviceName: UIDevice.current.initialDisplayName,
deviceId: nil) {
case .success(let userSession):
callback?(.signedIn(userSession))
stopLoading()
@@ -0,0 +1,68 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockSoftLogoutScreenState: String, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case emptyPassword
case enteredPassword
case oidc
case unsupported
case keyBackupNeeded
/// Generate the view struct for the screen state.
@MainActor var viewModel: SoftLogoutViewModel {
let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org",
homeserverName: "matrix.org",
userDisplayName: "mock",
deviceId: nil)
switch self {
case .emptyPassword:
return SoftLogoutViewModel(credentials: credentials,
homeserver: .mockMatrixDotOrg,
keyBackupNeeded: false)
case .enteredPassword:
return SoftLogoutViewModel(credentials: credentials,
homeserver: .mockMatrixDotOrg,
keyBackupNeeded: false,
password: "12345678")
case .oidc:
return SoftLogoutViewModel(credentials: credentials,
homeserver: .mockOIDC,
keyBackupNeeded: false)
case .unsupported:
return SoftLogoutViewModel(credentials: credentials,
homeserver: .mockUnsupported,
keyBackupNeeded: false)
case .keyBackupNeeded:
return SoftLogoutViewModel(credentials: credentials,
homeserver: .mockMatrixDotOrg,
keyBackupNeeded: true)
}
}
}
extension MockSoftLogoutScreenState: Identifiable {
var id: String {
rawValue
}
}
@@ -0,0 +1,184 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import AppAuth
import MatrixRustSDK
import SwiftUI
struct SoftLogoutCoordinatorParameters {
let authenticationService: AuthenticationServiceProxyProtocol
let credentials: SoftLogoutCredentials
let keyBackupNeeded: Bool
}
enum SoftLogoutCoordinatorResult: CustomStringConvertible {
/// Login was successful.
case signedIn(UserSessionProtocol)
/// Clear all user data
case clearAllData
/// A string representation of the result, ignoring any associated values that could leak PII.
var description: String {
switch self {
case .signedIn:
return "signedIn"
case .clearAllData:
return "clearAllData"
}
}
}
final class SoftLogoutCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: SoftLogoutCoordinatorParameters
private let softLogoutHostingController: UIViewController
private var softLogoutViewModel: SoftLogoutViewModelProtocol
/// Passed to the OIDC service to provide a view controller from which to present the authentication session.
private let oidcUserAgent: OIDExternalUserAgentIOS?
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var successIndicator: UserIndicator?
/// The wizard used to handle the registration flow.
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (SoftLogoutCoordinatorResult) -> Void)?
// MARK: - Setup
@MainActor init(parameters: SoftLogoutCoordinatorParameters) {
self.parameters = parameters
let homeserver = parameters.authenticationService.homeserver
let viewModel = SoftLogoutViewModel(credentials: parameters.credentials,
homeserver: homeserver,
keyBackupNeeded: parameters.keyBackupNeeded)
softLogoutViewModel = viewModel
let view = SoftLogoutScreen(context: viewModel.context)
softLogoutHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: softLogoutHostingController)
oidcUserAgent = OIDExternalUserAgentIOS(presenting: softLogoutHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[SoftLogoutCoordinator] did start.")
softLogoutViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[SoftLogoutCoordinator] SoftLogoutViewModel did complete with result: \(result).")
switch result {
case .login(let password):
self.login(withPassword: password)
case .forgotPassword:
self.showForgotPasswordScreen()
case .clearAllData:
self.callback?(.clearAllData)
case .continueWithOIDC:
self.loginWithOIDC()
}
}
}
func toPresentable() -> UIViewController {
softLogoutHostingController
}
// MARK: - Private
/// Show an activity indicator whilst loading.
@MainActor private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
@MainActor private func stopLoading() {
loadingIndicator = nil
}
/// Shows the forgot password screen.
@MainActor private func showForgotPasswordScreen() {
MXLog.debug("[SoftLogoutCoordinator] showForgotPasswordScreen")
softLogoutViewModel.displayError(.alert("Not implemented."))
}
/// Login with the supplied username and password.
@MainActor private func login(withPassword password: String) {
let username = parameters.credentials.userId
startLoading()
Task {
switch await authenticationService.login(username: username,
password: password,
initialDeviceName: UIDevice.current.initialDisplayName,
deviceId: parameters.credentials.deviceId) {
case .success(let userSession):
callback?(.signedIn(userSession))
stopLoading()
case .failure(let error):
stopLoading()
handleError(error)
}
}
}
private func loginWithOIDC() {
guard let oidcUserAgent = oidcUserAgent else {
handleError(AuthenticationServiceError.oidcError(.notSupported))
return
}
startLoading()
Task {
switch await authenticationService.loginWithOIDC(userAgent: oidcUserAgent) {
case .success(let userSession):
callback?(.signedIn(userSession))
stopLoading()
case .failure(let error):
stopLoading()
handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.
private func handleError(_ error: AuthenticationServiceError) {
switch error {
case .invalidCredentials:
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
case .accountDeactivated:
softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
default:
softLogoutViewModel.displayError(.alert(ElementL10n.unknownError))
}
}
}
@@ -0,0 +1,107 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
// MARK: Data
struct SoftLogoutCredentials {
let userId: String
let homeserverName: String
let userDisplayName: String
let deviceId: String?
}
// MARK: View model
enum SoftLogoutViewModelAction: CustomStringConvertible {
/// Login with password
case login(String)
/// Forgot password
case forgotPassword
/// Clear all user data
case clearAllData
/// Continue using OIDC.
case continueWithOIDC
/// A string representation of the result, ignoring any associated values that could leak PII.
var description: String {
switch self {
case .login:
return "login"
case .forgotPassword:
return "forgotPassword"
case .clearAllData:
return "clearAllData"
case .continueWithOIDC:
return "continueWithOIDC"
}
}
}
// MARK: View
struct SoftLogoutViewState: BindableState {
/// Soft logout credentials
var credentials: SoftLogoutCredentials
/// Data about the selected homeserver.
var homeserver: LoginHomeserver
/// Flag indicating soft logged out user needs backup for some keys
var keyBackupNeeded: Bool
/// View state that can be bound to from SwiftUI.
var bindings: SoftLogoutBindings
/// The types of login supported by the homeserver.
var loginMode: LoginMode { homeserver.loginMode }
/// Whether to show recover encryption keys message
var showRecoverEncryptionKeysMessage: Bool {
keyBackupNeeded
}
/// `true` when valid credentials have been entered and a homeserver has been loaded.
var canSubmit: Bool {
!bindings.password.isEmpty
}
}
struct SoftLogoutBindings {
/// The password input by the user.
var password: String
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<SoftLogoutErrorType>?
}
enum SoftLogoutViewAction {
/// Login.
case login
/// Forgot password
case forgotPassword
/// Clear all user data.
case clearAllData
/// Continue using OIDC.
case continueWithOIDC
}
enum SoftLogoutErrorType: Hashable {
/// A specific error message shown in an alert.
case alert(String)
/// An unknown error occurred.
case unknown
}
@@ -0,0 +1,69 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias SoftLogoutViewModelType = StateStoreViewModel<SoftLogoutViewState, SoftLogoutViewAction>
class SoftLogoutViewModel: SoftLogoutViewModelType, SoftLogoutViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: (@MainActor (SoftLogoutViewModelAction) -> Void)?
// MARK: - Setup
init(credentials: SoftLogoutCredentials,
homeserver: LoginHomeserver,
keyBackupNeeded: Bool,
password: String = "") {
let bindings = SoftLogoutBindings(password: password)
let viewState = SoftLogoutViewState(credentials: credentials,
homeserver: homeserver,
keyBackupNeeded: keyBackupNeeded,
bindings: bindings)
super.init(initialViewState: viewState)
}
// MARK: - Public
override func process(viewAction: SoftLogoutViewAction) async {
switch viewAction {
case .login:
callback?(.login(state.bindings.password))
case .forgotPassword:
callback?(.forgotPassword)
case .clearAllData:
callback?(.clearAllData)
case .continueWithOIDC:
callback?(.continueWithOIDC)
}
}
@MainActor func displayError(_ type: SoftLogoutErrorType) {
switch type {
case .alert(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: ElementL10n.dialogTitleError,
message: message)
case .unknown:
state.bindings.alertInfo = AlertInfo(id: type)
}
}
}
@@ -0,0 +1,25 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol SoftLogoutViewModelProtocol {
var callback: (@MainActor (SoftLogoutViewModelAction) -> Void)? { get set }
var context: SoftLogoutViewModelType.Context { get }
/// Display an error to the user.
@MainActor func displayError(_ type: SoftLogoutErrorType)
}
@@ -0,0 +1,190 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct SoftLogoutScreen: View {
// MARK: - Properties
// MARK: Private
/// The focus state of the password text field.
@FocusState private var isPasswordFocused: Bool
// MARK: Public
@ObservedObject var context: SoftLogoutViewModel.Context
// MARK: Views
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
.padding(.top, UIConstants.topPaddingToNavigationBar)
.padding(.bottom, 36)
switch context.viewState.loginMode {
case .password:
loginForm
case .oidc:
oidcButton
default:
loginUnavailableText
}
clearDataForm
.padding(.top, 16)
}
.readableFrame()
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.background(Color.element.background.ignoresSafeArea())
.alert(item: $context.alertInfo) { $0.alert }
}
/// The title, message and icon at the top of the screen.
var header: some View {
VStack(alignment: .leading, spacing: 16) {
Text(ElementL10n.softLogoutSigninTitle)
.font(.element.title2Bold)
.multilineTextAlignment(.leading)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("titleLabel")
Text(ElementL10n.softLogoutSigninNotice(context.viewState.credentials.homeserverName, context.viewState.credentials.userDisplayName, context.viewState.credentials.userId))
.font(.element.body)
.multilineTextAlignment(.leading)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("messageLabel1")
if context.viewState.showRecoverEncryptionKeysMessage {
Text(ElementL10n.softLogoutSigninE2eWarningNotice)
.font(.element.body)
.multilineTextAlignment(.leading)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("messageLabel2")
}
}
}
/// The form with text fields for username and password, along with a submit button.
var loginForm: some View {
VStack(spacing: 14) {
SecureField(ElementL10n.loginSignupPasswordHint, text: $context.password)
.focused($isPasswordFocused)
.textFieldStyle(.elementInput())
.textContentType(.password)
.submitLabel(.done)
.onSubmit(submit)
.accessibilityIdentifier("passwordTextField")
Button { context.send(viewAction: .forgotPassword) } label: {
Text(ElementL10n.authenticationLoginForgotPassword)
.font(.element.body)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.bottom, 8)
.accessibilityIdentifier("forgotPasswordButton")
Button(action: submit) {
Text(ElementL10n.loginSignupSubmit)
}
.buttonStyle(.elementAction(.xLarge))
.disabled(!context.viewState.canSubmit)
.accessibilityIdentifier("nextButton")
}
}
/// The OIDC button that can be used for login.
var oidcButton: some View {
Button { context.send(viewAction: .continueWithOIDC) } label: {
Text(ElementL10n.loginContinue)
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier("oidcButton")
}
/// Text shown if neither password or OIDC login is supported.
var loginUnavailableText: some View {
Text(ElementL10n.autodiscoverWellKnownError)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
.frame(maxWidth: .infinity)
.accessibilityIdentifier("unsupportedServerText")
}
/// The text field and submit button where the user enters an email address.
var clearDataForm: some View {
VStack(alignment: .leading, spacing: 12) {
Text(ElementL10n.softLogoutClearDataTitle)
.font(.element.title2Bold)
.multilineTextAlignment(.leading)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("clearDataTitleLabel")
Text(ElementL10n.softLogoutClearDataNotice)
.font(.element.body)
.multilineTextAlignment(.leading)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("clearDataMessageLabel")
.padding(.bottom, 12)
Button(action: clearData) {
Text(ElementL10n.softLogoutClearDataSubmit)
}
.buttonStyle(.elementAction(.xLarge, color: .element.alert))
.accessibilityIdentifier("clearDataButton")
}
}
/// Sends the `login` view action so long as a valid email address has been input.
func submit() {
guard context.viewState.canSubmit else { return }
context.send(viewAction: .login)
}
/// Sends the `forgotPassword` view action.
func forgotPassword() {
context.send(viewAction: .forgotPassword)
}
/// Sends the `clearAllData` view action.
func clearData() {
context.send(viewAction: .clearAllData)
}
}
// MARK: - Previews
struct SoftLogout_Previews: PreviewProvider {
static var previews: some View {
ForEach(MockSoftLogoutScreenState.allCases) { state in
screen(for: state.viewModel)
}
}
static func screen(for viewModel: SoftLogoutViewModel) -> some View {
NavigationView {
SoftLogoutScreen(context: viewModel.context)
.navigationBarTitleDisplayMode(.inline)
.tint(.element.accent)
}
.navigationViewStyle(.stack)
}
}
@@ -90,6 +90,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
self?.viewModel.showSessionVerificationBanner()
case .didVerifySession:
self?.viewModel.hideSessionVerificationBanner()
default:
break
}
}.store(in: &cancellables)
@@ -104,11 +104,17 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
}
}
func login(username: String, password: String) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
let loginTask: Task<Client, Error> = Task.detached {
try self.authenticationService.login(username: username, password: password)
#warning("Use new api on next SDK release.")
return try self.authenticationService.login(username: username,
password: password)
// try self.authenticationService.login(username: username,
// password: password,
// initialDeviceName: initialDeviceName,
// deviceId: deviceId)
}
switch await loginTask.result {
@@ -36,5 +36,5 @@ protocol AuthenticationServiceProxyProtocol {
/// Performs login using OIDC for the current homeserver.
func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result<UserSessionProtocol, AuthenticationServiceError>
/// Performs a password login using the current homeserver.
func login(username: String, password: String) async -> Result<UserSessionProtocol, AuthenticationServiceError>
func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError>
}
@@ -45,8 +45,8 @@ class MockAuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
.failure(.oidcError(.notSupported))
}
func login(username: String, password: String) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
// Login only succeeds if the username and password match the valid credentials property
guard username == validCredentials.username, password == validCredentials.password else {
return .failure(.invalidCredentials)
@@ -28,7 +28,7 @@ class ScreenshotDetector {
var callback: (@MainActor (UIImage?, Error?) -> Void)?
/// Flag to whether ask for photos authorization by default if needed.
var autoRequestPHAuthorization = true
var autoRequestPHAuthorization = false
init() {
startObservingScreenshots()
@@ -29,6 +29,14 @@ private class WeakClientProxyWrapper: ClientDelegate {
func didReceiveSyncUpdate() {
clientProxy?.didReceiveSyncUpdate()
}
func didReceiveAuthError(isSoftLogout: Bool) {
clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout)
}
func didUpdateRestoreToken() {
clientProxy?.didUpdateRestoreToken()
}
}
class ClientProxy: ClientProxyProtocol {
@@ -57,11 +65,18 @@ class ClientProxy: ClientProxyProtocol {
self.backgroundTaskService = backgroundTaskService
client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self))
#warning("Use isSoftLogout() api on next SDK release.")
Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.")
client.startSync(timelineLimit: ClientProxy.syncLimit)
Task { await updateRooms() }
// if !client.isSoftLogout() {
// Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.")
// client.startSync(timelineLimit: ClientProxy.syncLimit)
//
// Task { await updateRooms() }
// }
}
var userIdentifier: String {
@@ -72,6 +87,34 @@ class ClientProxy: ClientProxyProtocol {
return "Unknown user identifier"
}
}
var isSoftLogout: Bool {
#warning("Use isSoftLogout() api on next SDK release.")
return false
// client.isSoftLogout()
}
var deviceId: String? {
do {
return try client.deviceId()
} catch {
MXLog.error("Failed retrieving device id with error: \(error)")
return nil
}
}
var homeserver: String {
client.homeserver()
}
var restoreToken: String? {
do {
return try client.restoreToken()
} catch {
MXLog.error("Failed retrieving restore token with error: \(error)")
return nil
}
}
func loadUserDisplayName() async -> Result<String, ClientProxyError> {
await Task.detached {
@@ -91,7 +134,7 @@ class ClientProxy: ClientProxyProtocol {
let avatarURL = try self.client.avatarUrl()
return .success(avatarURL)
} catch {
return .failure(.failedRetrievingDisplayName)
return .failure(.failedRetrievingAvatarURL)
}
}
.value
@@ -125,6 +168,15 @@ class ClientProxy: ClientProxyProtocol {
}
.value
}
func logout() async {
do {
#warning("Use logout() api on next SDK release.")
// try client.logout()
} catch {
MXLog.error("Failed logging out with error: \(error)")
}
}
// MARK: Private
@@ -137,6 +189,14 @@ class ClientProxy: ClientProxyProtocol {
await self.updateRooms()
}
}
fileprivate func didReceiveAuthError(isSoftLogout: Bool) {
callbacks.send(.receivedAuthError(isSoftLogout: isSoftLogout))
}
fileprivate func didUpdateRestoreToken() {
callbacks.send(.updatedRestoreToken)
}
private func updateRooms() async {
var currentRooms = rooms
@@ -21,6 +21,8 @@ import MatrixRustSDK
enum ClientProxyCallback {
case updatedRoomsList
case receivedSyncUpdate
case receivedAuthError(isSoftLogout: Bool)
case updatedRestoreToken
}
enum ClientProxyError: Error {
@@ -36,6 +38,14 @@ protocol ClientProxyProtocol {
var callbacks: PassthroughSubject<ClientProxyCallback, Never> { get }
var userIdentifier: String { get }
var isSoftLogout: Bool { get }
var deviceId: String? { get }
var homeserver: String { get }
var restoreToken: String? { get }
var rooms: [RoomProxy] { get }
@@ -52,4 +62,6 @@ protocol ClientProxyProtocol {
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError>
func logout() async
}
@@ -21,6 +21,10 @@ struct MockClientProxy: ClientProxyProtocol {
let callbacks = PassthroughSubject<ClientProxyCallback, Never>()
let userIdentifier: String
let isSoftLogout = false
let deviceId: String? = nil
let homeserver = ""
let restoreToken: String? = nil
let rooms = [RoomProxy]()
@@ -51,4 +55,8 @@ struct MockClientProxy: ClientProxyProtocol {
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError> {
.failure(.failedRetrievingSessionVerificationController)
}
func logout() async {
// no-op
}
}
@@ -21,6 +21,9 @@ struct MockUserSession: UserSessionProtocol {
let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil
var userID: String { clientProxy.userIdentifier }
var isSoftLogout: Bool { clientProxy.isSoftLogout }
var deviceId: String? { clientProxy.deviceId }
var homeserver: String { clientProxy.homeserver }
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
}
@@ -20,9 +20,14 @@ import Foundation
class UserSession: UserSessionProtocol {
private var cancellables = Set<AnyCancellable>()
private var checkForSessionVerificationControllerCancellable: AnyCancellable?
private var authErrorCancellable: AnyCancellable?
private var restoreTokenUpdateCancellable: AnyCancellable?
var userID: String { clientProxy.userIdentifier }
var isSoftLogout: Bool { clientProxy.isSoftLogout }
var deviceId: String? { clientProxy.deviceId }
var homeserver: String { clientProxy.homeserver }
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
@@ -33,6 +38,8 @@ class UserSession: UserSessionProtocol {
self.mediaProvider = mediaProvider
setupSessionVerificationWatchdog()
setupAuthErrorWatchdog()
setupRestoreTokenUpdateWatchdog()
}
// MARK: - Private
@@ -77,4 +84,38 @@ class UserSession: UserSessionProtocol {
private func tearDownSessionVerificationControllerWatchdog() {
checkForSessionVerificationControllerCancellable = nil
}
// MARK: Auth Error Watchdog
private func setupAuthErrorWatchdog() {
authErrorCancellable = clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
if case .receivedAuthError(let isSoftLogout) = callback {
self?.callbacks.send(.didReceiveAuthError(isSoftLogout: isSoftLogout))
self?.tearDownAuthErrorWatchdog()
}
}
}
private func tearDownAuthErrorWatchdog() {
authErrorCancellable = nil
}
// MARK: Restore Token Update Watchdog
private func setupRestoreTokenUpdateWatchdog() {
restoreTokenUpdateCancellable = clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
if case .updatedRestoreToken = callback {
self?.callbacks.send(.updateRestoreTokenNeeded)
self?.tearDownRestoreTokenUpdateWatchdog()
}
}
}
private func tearDownRestoreTokenUpdateWatchdog() {
restoreTokenUpdateCancellable = nil
}
}
@@ -20,10 +20,15 @@ import Foundation
enum UserSessionCallback {
case sessionVerificationNeeded
case didVerifySession
case didReceiveAuthError(isSoftLogout: Bool)
case updateRestoreTokenNeeded
}
protocol UserSessionProtocol {
var userID: String { get }
var isSoftLogout: Bool { get }
var deviceId: String? { get }
var homeserver: String { get }
var clientProxy: ClientProxyProtocol { get }
var mediaProvider: MediaProviderProtocol { get }
@@ -69,6 +69,16 @@ class UserSessionStore: UserSessionStoreProtocol {
return .failure(error)
}
}
func refreshRestoreToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError> {
guard let accessToken = userSession.clientProxy.restoreToken else {
return .failure(.failedRefreshingRestoreToken)
}
keychainController.setRestoreToken(accessToken, forUsername: userSession.clientProxy.userIdentifier)
return .success(())
}
func logout(userSession: UserSessionProtocol) {
let userID = userSession.clientProxy.userIdentifier
@@ -21,6 +21,7 @@ enum UserSessionStoreError: Error {
case missingCredentials
case failedRestoringLogin
case failedSettingUpSession
case failedRefreshingRestoreToken
}
@MainActor
@@ -36,6 +37,9 @@ protocol UserSessionStoreProtocol {
/// Creates a user session for a new client from the SDK.
func userSession(for client: Client) async -> Result<UserSession, UserSessionStoreError>
/// Refresh the restore token of the client for a given session.
func refreshRestoreToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError>
/// Logs out of the specified session.
func logout(userSession: UserSessionProtocol)
@@ -21,6 +21,7 @@ enum UITestScreenIdentifier: String {
case serverSelection
case serverSelectionNonModal
case authenticationFlow
case softLogout
case analyticsPrompt
case simpleRegular
case simpleUpgrade
@@ -82,6 +82,14 @@ class MockScreen: Identifiable {
case .authenticationFlow:
return AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(),
navigationRouter: navigationRouter)
case .softLogout:
let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org",
homeserverName: "matrix.org",
userDisplayName: "mock",
deviceId: "ABCDEFGH")
return SoftLogoutCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(),
credentials: credentials,
keyBackupNeeded: false))
case .simpleRegular:
return TemplateCoordinator(parameters: .init(promptType: .regular))
case .simpleUpgrade: