diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartCoordinator.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartCoordinator.swift new file mode 100644 index 000000000..76910a825 --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartCoordinator.swift @@ -0,0 +1,77 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 UIKit + +final class UserVerificationStartCoordinator: UserVerificationStartCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let roomMember: MXRoomMember + + private var userVerificationStartViewModel: UserVerificationStartViewModelType + private let userVerificationStartViewController: UserVerificationStartViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: UserVerificationStartCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession, roomMember: MXRoomMember) { + self.session = session + self.roomMember = roomMember + + let userVerificationStartViewModel = UserVerificationStartViewModel(session: self.session, roomMember: self.roomMember) + let userVerificationStartViewController = UserVerificationStartViewController.instantiate(with: userVerificationStartViewModel) + self.userVerificationStartViewModel = userVerificationStartViewModel + self.userVerificationStartViewController = userVerificationStartViewController + } + + // MARK: - Public methods + + func start() { + self.userVerificationStartViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.userVerificationStartViewController + } +} + +// MARK: - UserVerificationStartViewModelCoordinatorDelegate +extension UserVerificationStartCoordinator: UserVerificationStartViewModelCoordinatorDelegate { + func userVerificationStartViewModelDidCancel(_ viewModel: UserVerificationStartViewModelType) { + self.delegate?.userVerificationStartCoordinatorDidCancel(self) + } + + func userVerificationStartViewModel(_ viewModel: UserVerificationStartViewModelType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) { + self.delegate?.userVerificationStartCoordinator(self, didCompleteWithOutgoingTransaction: transaction) + } + + func userVerificationStartViewModel(_ viewModel: UserVerificationStartViewModelType, didTransactionCancelled transaction: MXSASTransaction) { + self.delegate?.userVerificationStartCoordinator(self, didTransactionCancelled: transaction) + } +} diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartCoordinatorType.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartCoordinatorType.swift new file mode 100644 index 000000000..2f59b1973 --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartCoordinatorType.swift @@ -0,0 +1,32 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 UserVerificationStartCoordinatorDelegate: class { + func userVerificationStartCoordinator(_ coordinator: UserVerificationStartCoordinatorType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) + + func userVerificationStartCoordinator(_ coordinator: UserVerificationStartCoordinatorType, didTransactionCancelled transaction: MXSASTransaction) + + func userVerificationStartCoordinatorDidCancel(_ coordinator: UserVerificationStartCoordinatorType) +} + +/// `UserVerificationStartCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. +protocol UserVerificationStartCoordinatorType: Coordinator, Presentable { + var delegate: UserVerificationStartCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartViewAction.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartViewAction.swift new file mode 100644 index 000000000..3d0e50762 --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartViewAction.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 + +/// UserVerificationStartViewController view actions exposed to view model +enum UserVerificationStartViewAction { + case loadData + case startVerification + case cancel +} diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartViewController.storyboard b/Riot/Modules/UserVerification/Start/UserVerificationStartViewController.storyboard new file mode 100644 index 000000000..5af56d06b --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartViewController.storyboard @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartViewController.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartViewController.swift new file mode 100644 index 000000000..b57301d75 --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartViewController.swift @@ -0,0 +1,229 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 + +final class UserVerificationStartViewController: UIViewController { + + // MARK: - Constants + + private enum Constants { + static let verifyButtonCornerRadius: CGFloat = 8.0 + static let informationTextDefaultFont = UIFont.systemFont(ofSize: 15.0) + static let informationTextBoldFont = UIFont.systemFont(ofSize: 15.0, weight: .medium) + } + + // MARK: - Properties + + // MARK: Outlets + + @IBOutlet private weak var informationLabel: UILabel! + + @IBOutlet private weak var startVerificationButton: UIButton! + @IBOutlet private weak var verificationWaitingLabel: UILabel! + + @IBOutlet private weak var additionalInformationLabel: UILabel! + + // MARK: Private + + private var viewModel: UserVerificationStartViewModelType! + private var theme: Theme! + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + + // MARK: - Setup + + class func instantiate(with viewModel: UserVerificationStartViewModelType) -> UserVerificationStartViewController { + let viewController = StoryboardScene.UserVerificationStartViewController.initialScene.instantiate() + viewController.viewModel = viewModel + viewController.theme = ThemeService.shared().theme + return viewController + } + + // MARK: - Life cycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + + self.title = "Verify user" + + self.setupViews() + + self.activityPresenter = ActivityIndicatorPresenter() + self.errorPresenter = MXKErrorAlertPresentation() + + self.registerThemeServiceDidChangeThemeNotification() + self.update(theme: self.theme) + + self.viewModel.viewDelegate = self + + self.viewModel.process(viewAction: .loadData) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + self.startVerificationButton.layer.cornerRadius = Constants.verifyButtonCornerRadius + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.headerBackgroundColor + + if let navigationBar = self.navigationController?.navigationBar { + theme.applyStyle(onNavigationBar: navigationBar) + } + + self.informationLabel.textColor = theme.textPrimaryColor + self.startVerificationButton.vc_setBackgroundColor(theme.tintColor, for: .normal) + self.verificationWaitingLabel.textColor = theme.textSecondaryColor + self.additionalInformationLabel.textColor = theme.textSecondaryColor + } + + private func registerThemeServiceDidChangeThemeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil) + } + + @objc private func themeDidChange() { + self.update(theme: ThemeService.shared().theme) + } + + private func setupViews() { + let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in + self?.cancelButtonAction() + } + + self.navigationItem.rightBarButtonItem = cancelBarButtonItem + + self.startVerificationButton.layer.masksToBounds = true + self.startVerificationButton.setTitle("Start verification", for: .normal) + } + + private func render(viewState: UserVerificationStartViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded(let viewData): + self.renderLoaded(viewData: viewData) + case .error(let error): + self.render(error: error) + case .verificationPending: + self.renderVerificationPending() + case .cancelled(let reason): + self.renderCancelled(reason: reason) + case .cancelledByMe(let reason): + self.renderCancelledByMe(reason: reason) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(viewData: UserVerificationStartViewData) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + + self.informationLabel.attributedText = self.buildInformationAttributedText(with: viewData.userId) + self.verificationWaitingLabel.text = self.buildVerificationWaitingText(with: viewData) + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + private func renderVerificationPending() { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.startVerificationButton.isHidden = true + self.verificationWaitingLabel.isHidden = false + } + + private func renderCancelled(reason: MXTransactionCancelCode) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + + self.errorPresenter.presentError(from: self, title: "", message: VectorL10n.deviceVerificationCancelled, animated: true) { + self.viewModel.process(viewAction: .cancel) + } + } + + private func renderCancelledByMe(reason: MXTransactionCancelCode) { + if reason.value != MXTransactionCancelCode.user().value { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + + self.errorPresenter.presentError(from: self, title: "", message: VectorL10n.deviceVerificationCancelledByMe(reason.humanReadable), animated: true) { + self.viewModel.process(viewAction: .cancel) + } + } else { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + } + } + + private func buildInformationAttributedText(with userId: String) -> NSAttributedString { + + let informationAttributedText: NSMutableAttributedString = NSMutableAttributedString() + + let informationTextDefaultAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: self.theme.textPrimaryColor, + .font: Constants.informationTextDefaultFont] + + let informationTextBoldAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: self.theme.textPrimaryColor, + .font: Constants.informationTextBoldFont] + + let informationAttributedStringPart1 = NSAttributedString(string: "For extra security, verify ", attributes: informationTextDefaultAttributes) + let informationAttributedStringPart2 = NSAttributedString(string: userId, attributes: informationTextBoldAttributes) + let informationAttributedStringPart3 = NSAttributedString(string: " by checking a one-time code on both your devices.", attributes: informationTextDefaultAttributes) + + informationAttributedText.append(informationAttributedStringPart1) + informationAttributedText.append(informationAttributedStringPart2) + informationAttributedText.append(informationAttributedStringPart3) + + return informationAttributedText + } + + private func buildVerificationWaitingText(with viewData: UserVerificationStartViewData) -> String { + let userName = viewData.userDisplayName ?? viewData.userId + return "Waiting for \(userName)…" + } + + // MARK: - Actions + + @IBAction private func startVerificationButtonAction(_ sender: Any) { + self.viewModel.process(viewAction: .startVerification) + } + + private func cancelButtonAction() { + self.viewModel.process(viewAction: .cancel) + } +} + + +// MARK: - UserVerificationStartViewModelViewDelegate +extension UserVerificationStartViewController: UserVerificationStartViewModelViewDelegate { + + func userVerificationStartViewModel(_ viewModel: UserVerificationStartViewModelType, didUpdateViewState viewSate: UserVerificationStartViewState) { + self.render(viewState: viewSate) + } +} diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartViewModel.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartViewModel.swift new file mode 100644 index 000000000..4ef9b3e0d --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartViewModel.swift @@ -0,0 +1,200 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 + +enum UserVerificationStartViewModelError: Error { + case keyVerificationRequestExpired +} + +struct UserVerificationStartViewData { + let userId: String + let userDisplayName: String? + let userAvatarURL: String? +} + +final class UserVerificationStartViewModel: UserVerificationStartViewModelType { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let roomMember: MXRoomMember + private let verificationManager: MXDeviceVerificationManager + + private var keyVerificationRequest: MXKeyVerificationRequest? + + private var viewData: UserVerificationStartViewData { + return UserVerificationStartViewData(userId: self.roomMember.userId, userDisplayName: self.roomMember.displayname, userAvatarURL: self.roomMember.avatarUrl) + } + + // MARK: Public + + weak var viewDelegate: UserVerificationStartViewModelViewDelegate? + weak var coordinatorDelegate: UserVerificationStartViewModelCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession, roomMember: MXRoomMember) { + self.session = session + self.verificationManager = session.crypto.deviceVerificationManager + self.roomMember = roomMember + } + + deinit { + } + + // MARK: - Public + + func process(viewAction: UserVerificationStartViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .startVerification: + self.startVerification() + case .cancel: + self.cancelKeyVerificationRequest() + self.coordinatorDelegate?.userVerificationStartViewModelDidCancel(self) + } + } + + // MARK: - Private + + private func loadData() { + self.update(viewState: .loaded(self.viewData)) + } + + private func startVerification() { + self.update(viewState: .verificationPending) + + self.verificationManager.requestVerificationByDM(withUserId: self.roomMember.userId, + roomId: nil, + fallbackText: "", + methods: [MXKeyVerificationMethodSAS], + success: { [weak self] (keyVerificationRequest) in + guard let self = self else { + return + } + + self.keyVerificationRequest = keyVerificationRequest + self.update(viewState: .loaded(self.viewData)) + self.registerKeyVerificationDidChangeNotification(keyVerificationRequest: keyVerificationRequest) + self.registerTransactionDidStateChangeNotification() + + }, failure: { [weak self] error in + self?.update(viewState: .error(error)) + }) + } + + private func update(viewState: UserVerificationStartViewState) { + self.viewDelegate?.userVerificationStartViewModel(self, didUpdateViewState: viewState) + } + + private func cancelKeyVerificationRequest() { + guard let keyVerificationRequest = self.keyVerificationRequest else { + return + } + + keyVerificationRequest.cancel(with: MXTransactionCancelCode.user(), success: nil, failure: nil) + } + + // MARK: - MXDeviceVerificationTransactionDidChange + + private func registerTransactionDidStateChangeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: .MXDeviceVerificationTransactionDidChange, object: nil) + } + + private func unregisterTransactionDidStateChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .MXDeviceVerificationTransactionDidChange, object: nil) + } + + @objc private func transactionDidStateChange(notification: Notification) { + guard let transaction = notification.object as? MXIncomingSASTransaction else { + return + } + + guard let keyVerificationRequest = self.keyVerificationRequest, + let transactionDMEventId = transaction.dmEventId, + keyVerificationRequest.requestId == transactionDMEventId else { + return + } + + switch transaction.state { + case MXSASTransactionStateShowSAS: + self.unregisterTransactionDidStateChangeNotification() + self.coordinatorDelegate?.userVerificationStartViewModel(self, didCompleteWithOutgoingTransaction: transaction) + case MXSASTransactionStateCancelled: + guard let reason = transaction.reasonCancelCode else { + return + } + self.unregisterTransactionDidStateChangeNotification() + self.update(viewState: .cancelled(reason)) + case MXSASTransactionStateCancelledByMe: + guard let reason = transaction.reasonCancelCode else { + return + } + self.unregisterTransactionDidStateChangeNotification() + self.update(viewState: .cancelledByMe(reason)) + default: + break + } + } + + // MARK: - MXDeviceVerificationTransactionDidChange + + private func registerKeyVerificationDidChangeNotification(keyVerificationRequest: MXKeyVerificationRequest) { + NotificationCenter.default.addObserver(self, selector: #selector(keyVerificationRequestDidChange(notification:)), name: .MXKeyVerificationRequestDidChange, object: keyVerificationRequest) + } + + private func unregisterKeyVerificationDidChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationRequestDidChange, object: nil) + } + + @objc private func keyVerificationRequestDidChange(notification: Notification) { + guard let keyVerificationRequest = notification.object as? MXKeyVerificationByDMRequest else { + return + } + + guard let currentKeyVerificationRequest = self.keyVerificationRequest, keyVerificationRequest.requestId == currentKeyVerificationRequest.requestId else { + return + } + + switch keyVerificationRequest.state { + case MXKeyVerificationRequestStateAccepted: + self.unregisterKeyVerificationDidChangeNotification() + case MXKeyVerificationRequestStateCancelled: + guard let reason = keyVerificationRequest.reasonCancelCode else { + return + } + self.unregisterKeyVerificationDidChangeNotification() + self.update(viewState: .cancelled(reason)) + case MXKeyVerificationRequestStateCancelledByMe: + guard let reason = keyVerificationRequest.reasonCancelCode else { + return + } + self.unregisterKeyVerificationDidChangeNotification() + self.update(viewState: .cancelledByMe(reason)) + case MXKeyVerificationRequestStateExpired: + self.unregisterKeyVerificationDidChangeNotification() + self.update(viewState: .error(UserVerificationStartViewModelError.keyVerificationRequestExpired)) + default: + break + } + } +} diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartViewModelType.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartViewModelType.swift new file mode 100644 index 000000000..f50a4ccb8 --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartViewModelType.swift @@ -0,0 +1,41 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 UserVerificationStartViewModelViewDelegate: class { + func userVerificationStartViewModel(_ viewModel: UserVerificationStartViewModelType, didUpdateViewState viewSate: UserVerificationStartViewState) +} + +protocol UserVerificationStartViewModelCoordinatorDelegate: class { + + func userVerificationStartViewModel(_ viewModel: UserVerificationStartViewModelType, didCompleteWithOutgoingTransaction transaction: MXSASTransaction) + + func userVerificationStartViewModel(_ viewModel: UserVerificationStartViewModelType, didTransactionCancelled transaction: MXSASTransaction) + + func userVerificationStartViewModelDidCancel(_ viewModel: UserVerificationStartViewModelType) +} + +/// Protocol describing the view model used by `UserVerificationStartViewController` +protocol UserVerificationStartViewModelType { + + var viewDelegate: UserVerificationStartViewModelViewDelegate? { get set } + var coordinatorDelegate: UserVerificationStartViewModelCoordinatorDelegate? { get set } + + func process(viewAction: UserVerificationStartViewAction) +} diff --git a/Riot/Modules/UserVerification/Start/UserVerificationStartViewState.swift b/Riot/Modules/UserVerification/Start/UserVerificationStartViewState.swift new file mode 100644 index 000000000..efbb54c56 --- /dev/null +++ b/Riot/Modules/UserVerification/Start/UserVerificationStartViewState.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Start UserVerificationStart +/* + Copyright 2020 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 + +/// UserVerificationStartViewController view state +enum UserVerificationStartViewState { + case loading + case loaded(UserVerificationStartViewData) + case verificationPending + case cancelled(MXTransactionCancelCode) + case cancelledByMe(MXTransactionCancelCode) + case error(Error) +}