* 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
@@ -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)
}
}