vector-im/element-x-ios/issues/31 - Implement simple SaS sesson verification

* vector-im/element-x-ios/issues/31 - Session verification unit and UI tests
* Fix ImageAnonymizer tests when running on Apple Silicon
* Rename SessionVerification to SessionVerificationScreen
This commit is contained in:
Stefan Ceriu
2022-07-01 13:56:52 +03:00
committed by GitHub
parent 266ca656fc
commit a6f9d1ba89
30 changed files with 1604 additions and 98 deletions
+87 -39
View File
@@ -7,11 +7,12 @@
//
import UIKit
import Combine
class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private let window: UIWindow
private var stateMachine: AppCoordinatorStateMachine
private let stateMachine: AppCoordinatorStateMachine
private let mainNavigationController: UINavigationController
private let splashViewController: UIViewController
@@ -120,7 +121,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
case (.signingIn, .succeededSigningIn, .homeScreen):
self.hideLoadingIndicator()
self.presentHomeScreen()
case (.initial, .startWithExistingSession, .restoringSession):
self.showLoadingIndicator()
self.restoreUserSession()
@@ -142,10 +143,17 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
self.tearDownUserSession()
case (.signingOut, .failedSigningOut, _):
self.showLogoutErrorToast()
case (.homeScreen, .showSettingsScreen, .settingsScreen):
self.presentSettingsScreen()
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
self.tearDownDismissedSettingsScreen()
case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen):
self.presentSessionVerification()
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen):
self.tearDownDismissedSessionVerificationScreen()
default:
fatalError("Unknown transition: \(context)")
}
@@ -205,6 +213,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier))
case .presentSettings:
self.stateMachine.processEvent(.showSettingsScreen)
case .verifySession:
self.stateMachine.processEvent(.showSessionVerificationScreen)
}
}
@@ -215,28 +225,9 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
showCrashPopup()
}
}
private func presentSettingsScreen() {
let parameters = SettingsCoordinatorParameters(navigationRouter: navigationRouter,
bugReportService: bugReportService)
let coordinator = SettingsCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
guard let self = self else { return }
switch action {
case .logout:
self.stateMachine.processEvent(.attemptSignOut)
}
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
guard let self = self else { return }
self.stateMachine.processEvent(.dismissedSettingsScreen)
}
}
// MARK: Rooms
private func presentRoomWithIdentifier(_ roomIdentifier: String) {
guard let roomProxy = userSession.clientProxy.rooms.first(where: { $0.id == roomIdentifier }) else {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
@@ -276,7 +267,30 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
remove(childCoordinator: coordinator)
}
// MARK: Settings
private func presentSettingsScreen() {
let parameters = SettingsCoordinatorParameters(navigationRouter: navigationRouter,
bugReportService: bugReportService)
let coordinator = SettingsCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
guard let self = self else { return }
switch action {
case .logout:
self.stateMachine.processEvent(.attemptSignOut)
}
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.push(coordinator) { [weak self] in
guard let self = self else { return }
self.stateMachine.processEvent(.dismissedSettingsScreen)
}
}
private func tearDownDismissedSettingsScreen() {
guard let coordinator = childCoordinators.last as? SettingsCoordinator else {
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
@@ -285,22 +299,6 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
remove(childCoordinator: coordinator)
}
private func showLoadingIndicator() {
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
}
private func hideLoadingIndicator() {
loadingIndicator = nil
}
private func showLoginErrorToast() {
statusIndicator = indicatorPresenter.present(.error(label: "Failed logging in"))
}
private func showLogoutErrorToast() {
statusIndicator = indicatorPresenter.present(.error(label: "Failed logging out"))
}
private func showCrashPopup() {
let alert = UIAlertController(title: nil,
message: ElementL10n.sendBugReportAppCrashed,
@@ -360,4 +358,54 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
navigationRouter.dismissModule()
remove(childCoordinator: bugReportCoordinator)
}
// MARK: Session verification
private func presentSessionVerification() {
Task {
guard let sessionVerificationController = userSession.sessionVerificationController else {
fatalError("The sessionVerificationController should aways be valid at this point")
}
let parameters = SessionVerificationCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController)
let coordinator = SessionVerificationCoordinator(parameters: parameters)
coordinator.callback = { [weak self] in
self?.navigationRouter.dismissModule()
self?.stateMachine.processEvent(.dismissedSessionVerificationScreen)
}
add(childCoordinator: coordinator)
navigationRouter.present(coordinator)
coordinator.start()
}
}
private func tearDownDismissedSessionVerificationScreen() {
guard let coordinator = childCoordinators.last as? SessionVerificationCoordinator else {
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
}
remove(childCoordinator: coordinator)
}
// MARK: Toasts and loading indicators
private func showLoadingIndicator() {
loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
}
private func hideLoadingIndicator() {
loadingIndicator = nil
}
private func showLoginErrorToast() {
statusIndicator = indicatorPresenter.present(.error(label: "Failed logging in"))
}
private func showLogoutErrorToast() {
statusIndicator = indicatorPresenter.present(.error(label: "Failed logging out"))
}
}
@@ -22,11 +22,17 @@ class AppCoordinatorStateMachine {
case restoringSession
/// Showing the home screen
case homeScreen
/// Showing the settings screen
case settingsScreen
/// Showing a particular room's timeline
/// - Parameter roomId: that room's identifier
case roomScreen(roomId: String)
/// Showing the session verification flows
case sessionVerificationScreen
/// Showing the settings screen
case settingsScreen
/// Processing a sign out request
case signingOut
}
@@ -61,10 +67,16 @@ class AppCoordinatorStateMachine {
case showRoomScreen(roomId: String)
/// The room screen has been dismissed
case dismissedRoomScreen
/// The settings screen has been dismissed
case dismissedSettingsScreen
/// Request the start of the session verification flow
case showSessionVerificationScreen
/// Session verification has finished
case dismissedSessionVerificationScreen
/// Request settings screen presentation
case showSettingsScreen
/// The settings screen has been dismissed
case dismissedSettingsScreen
}
private let stateMachine: StateMachine<State, Event>
@@ -84,9 +96,13 @@ class AppCoordinatorStateMachine {
machine.addRoutes(event: .succeededSigningOut, transitions: [ .signingOut => .signedOut ])
machine.addRoutes(event: .failedSigningOut, transitions: [ .signingOut => .settingsScreen ])
machine.addRoutes(event: .showSettingsScreen, transitions: [ .homeScreen => .settingsScreen ])
machine.addRoutes(event: .dismissedSettingsScreen, transitions: [ .settingsScreen => .homeScreen ])
machine.addRoutes(event: .showSessionVerificationScreen, transitions: [ .homeScreen => .sessionVerificationScreen ])
machine.addRoutes(event: .dismissedSessionVerificationScreen, transitions: [ .sessionVerificationScreen => .homeScreen ])
// Transitions with associated values need to be handled through `addRouteMapping`
machine.addRouteMapping { event, fromState, _ in
switch (event, fromState) {
@@ -61,6 +61,12 @@ struct ImageAnonymizer {
}
// revision3 doesn't work!
faceRequest.revision = VNDetectFaceRectanglesRequestRevision2
#if targetEnvironment(simulator)
// Avoid `Could not create inference context` errors on Apple Silicon
// https://www.caseyliss.com/2022/6/20/feedback-is-broken-stop-trying-to-make-radar-happen
faceRequest.usesCPUOnly = true
#endif
// perform requests
try handler.perform([
@@ -26,6 +26,7 @@ struct HomeScreenCoordinatorParameters {
enum HomeScreenCoordinatorAction {
case presentRoom(roomIdentifier: String)
case presentSettings
case verifySession
}
final class HomeScreenCoordinator: Coordinator, Presentable {
@@ -66,19 +67,31 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
self.callback?(.presentRoom(roomIdentifier: roomIdentifier))
case .tapUserAvatar:
self.callback?(.presentSettings)
case .verifySession:
self.callback?(.verifySession)
}
}
parameters.userSession.clientProxy
.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
switch action {
case .updatedRoomsList:
.sink { [weak self] callback in
if case .updatedRoomsList = callback {
self?.updateRoomsList()
}
}.store(in: &cancellables)
parameters.userSession.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .sessionVerificationNeeded:
self?.viewModel.showSessionVerificationBanner()
case .didVerifySession:
self?.viewModel.hideSessionVerificationBanner()
}
}.store(in: &cancellables)
updateRoomsList()
Task {
@@ -20,18 +20,22 @@ import UIKit
enum HomeScreenViewModelAction {
case selectRoom(roomIdentifier: String)
case tapUserAvatar
case verifySession
}
enum HomeScreenViewAction {
case loadRoomData(roomIdentifier: String)
case selectRoom(roomIdentifier: String)
case tapUserAvatar
case verifySession
}
struct HomeScreenViewState: BindableState {
var userDisplayName: String?
var userAvatar: UIImage?
var showSessionVerificationBanner: Bool = false
var rooms: [HomeScreenRoom] = []
var isLoadingRooms: Bool = false
@@ -27,7 +27,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private var roomSummaries: [RoomSummaryProtocol]? {
didSet {
self.state.isLoadingRooms = (roomSummaries?.count ?? 0 == 0)
state.isLoadingRooms = (roomSummaries?.count ?? 0 == 0)
}
}
@@ -51,6 +51,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
callback?(.selectRoom(roomIdentifier: roomIdentifier))
case .tapUserAvatar:
callback?(.tapUserAvatar)
case .verifySession:
callback?(.verifySession)
}
}
@@ -84,11 +86,19 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
func updateWithUserAvatar(_ avatar: UIImage) {
self.state.userAvatar = avatar
state.userAvatar = avatar
}
func updateWithUserDisplayName(_ displayName: String) {
self.state.userDisplayName = displayName
state.userDisplayName = displayName
}
func showSessionVerificationBanner() {
state.showSessionVerificationBanner = true
}
func hideSessionVerificationBanner() {
state.showSessionVerificationBanner = false
}
// MARK: - Private
@@ -105,7 +115,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private func buildOrUpdateRoomFromSummary(_ roomSummary: RoomSummaryProtocol) -> HomeScreenRoom {
let lastMessage = lastMessageFromEventBrief(roomSummary.lastMessage)
guard var room = self.state.rooms.first(where: { $0.id == roomSummary.id }) else {
guard var room = state.rooms.first(where: { $0.id == roomSummary.id }) else {
return HomeScreenRoom(id: roomSummary.id,
displayName: roomSummary.displayName,
topic: roomSummary.topic,
@@ -26,4 +26,7 @@ protocol HomeScreenViewModelProtocol {
func updateWithUserAvatar(_ avatar: UIImage)
func updateWithUserDisplayName(_ displayName: String)
func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol])
func showSessionVerificationBanner()
func hideSessionVerificationBanner()
}
@@ -23,13 +23,27 @@ struct HomeScreen: View {
// MARK: Views
var body: some View {
VStack(spacing: 16.0) {
VStack(spacing: 0.0) {
if context.viewState.isLoadingRooms {
VStack {
Text(ElementL10n.loading)
ProgressView()
}
} else {
if context.viewState.showSessionVerificationBanner {
HStack {
Text(ElementL10n.verificationVerifyDevice)
Spacer()
Button(ElementL10n.startVerification) {
context.send(viewAction: .verifySession)
}
}
.padding()
.background(Color.element.quaternaryContent)
.padding(.top, 1)
}
List {
Section(ElementL10n.rooms) {
ForEach(context.viewState.visibleRooms) { room in
@@ -52,6 +66,8 @@ struct HomeScreen: View {
Spacer()
}
.background(Color.element.background)
.transition(.slide)
.animation(.default, value: context.viewState.showSessionVerificationBanner)
.ignoresSafeArea(.all, edges: .bottom)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -100,6 +116,8 @@ struct HomeScreen: View {
struct RoomCell: View {
@ScaledMetric private var avatarSize = 32.0
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
@@ -112,12 +130,12 @@ struct RoomCell: View {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
PlaceholderAvatarImage(text: room.displayName ?? room.id)
.clipShape(Circle())
.frame(width: 40, height: 40)
.frame(width: avatarSize, height: avatarSize)
}
VStack(alignment: .leading, spacing: 2.0) {
@@ -182,6 +200,8 @@ struct HomeScreen_Previews: PreviewProvider {
viewModel.updateWithUserAvatar(avatarImage)
}
viewModel.showSessionVerificationBanner()
return NavigationView {
HomeScreen(context: viewModel.context)
}
@@ -0,0 +1,67 @@
//
// Copyright 2021 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 SessionVerificationCoordinatorParameters {
let sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol
}
final class SessionVerificationCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: SessionVerificationCoordinatorParameters
private let sessionVerificationHostingController: UIViewController
private var sessionVerificationViewModel: SessionVerificationViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (() -> Void)?
// MARK: - Setup
init(parameters: SessionVerificationCoordinatorParameters) {
self.parameters = parameters
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: parameters.sessionVerificationControllerProxy)
let view = SessionVerificationScreen(context: viewModel.context)
sessionVerificationViewModel = viewModel
sessionVerificationHostingController = UIHostingController(rootView: view)
}
// MARK: - Public
func start() {
MXLog.debug("[SessionVerificationCoordinator] did start.")
sessionVerificationViewModel.callback = { [weak self] action in
guard let self = self else { return }
switch action {
case .finished:
self.callback?()
}
}
}
func toPresentable() -> UIViewController {
sessionVerificationHostingController
}
}
@@ -0,0 +1,46 @@
//
// Copyright 2021 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
// MARK: View model
enum SessionVerificationViewModelAction {
case finished
}
// MARK: View
struct SessionVerificationViewState: BindableState {
var verificationState: SessionVerificationStateMachine.State = .initial
var shouldDisableDismissButton: Bool {
verificationState != .verified
}
var shouldDisableCancelButton: Bool {
verificationState == .verified
}
}
enum SessionVerificationViewAction {
case start
case restart
case accept
case decline
case dismiss
case cancel
}
@@ -0,0 +1,135 @@
//
// SessionVerificationStateMachine.swift
// ElementX
//
// Created by Stefan Ceriu on 15/06/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftState
class SessionVerificationStateMachine {
/// States the SessionVerificationViewModel can find itself in
enum State: StateType {
/// The initial state, before verification started
case initial
/// Waiting for verification acceptance
case requestingVerification
/// Verification accepted and emojis received
case showingChallenge(emojis: [SessionVerificationEmoji])
/// Emojis match locally
case acceptingChallenge(emojis: [SessionVerificationEmoji])
/// Emojis do not match locally
case decliningChallenge(emojis: [SessionVerificationEmoji])
/// Verification successful
case verified
/// User requested verification cancellation
case cancelling
/// The verification has been cancelled, remotely or locally
case cancelled
}
/// Events that can be triggered on the SessionVerification state machine
enum Event: EventType {
/// Request verification
case requestVerification
/// Has received emojis
case didReceiveChallenge(emojis: [SessionVerificationEmoji])
/// Emojis match
case acceptChallenge
/// Emojis do not match
case declineChallenge
/// Remote accepted challenge
case didAcceptChallenge
/// Request cancellation
case cancel
/// Verification cancelled
case didCancel
/// Request failed
case didFail
/// Restart the verification flow
case restart
}
private let stateMachine: StateMachine<State, Event>
var state: State {
stateMachine.state
}
// swiftlint:disable cyclomatic_complexity
init() {
stateMachine = StateMachine(state: .initial) { machine in
machine.addRoutes(event: .requestVerification, transitions: [ .initial => .requestingVerification ])
machine.addRoutes(event: .didFail, transitions: [ .requestingVerification => .initial ])
machine.addRoutes(event: .cancel, transitions: [ .requestingVerification => .cancelling ])
machine.addRoutes(event: .didCancel, transitions: [ .requestingVerification => .cancelled ])
// Cancellation request from the other party should either take us from `.cancelling`
// to `.cancelled` or keep us in `.cancelled` if already there. There is more `.didCancel`
// handling in `addRouteMapping` for states containing associated values
machine.addRoutes(event: .didCancel, transitions: [ .cancelling => .cancelled ])
machine.addRoutes(event: .didCancel, transitions: [ .cancelled => .cancelled ])
machine.addRoutes(event: .didFail, transitions: [ .cancelled => .cancelled ])
machine.addRoutes(event: .restart, transitions: [ .cancelled => .initial ])
// Transitions with associated values need to be handled through `addRouteMapping`
machine.addRouteMapping { event, fromState, _ in
switch (event, fromState) {
case (.didReceiveChallenge(let emojis), .requestingVerification):
return .showingChallenge(emojis: emojis)
case (.acceptChallenge, .showingChallenge(let emojis)):
return .acceptingChallenge(emojis: emojis)
case (.didFail, .acceptingChallenge(let emojis)):
return .showingChallenge(emojis: emojis)
case (.didAcceptChallenge, .acceptingChallenge):
return .verified
case (.declineChallenge, .showingChallenge(let emojis)):
return .decliningChallenge(emojis: emojis)
case (.didFail, .decliningChallenge(let emojis)):
return .showingChallenge(emojis: emojis)
case (.cancel, .showingChallenge):
return .cancelling
case (.cancel, .acceptingChallenge):
return .cancelling
case (.cancel, .decliningChallenge):
return .cancelling
case (.didCancel, .showingChallenge):
return .cancelled
case (.didCancel, .acceptingChallenge):
return .cancelled
case (.didCancel, .decliningChallenge):
return .cancelled
default:
return nil
}
}
}
}
// swiftlint:enable cyclomatic_complexity
/// Attempt to move the state machine to another state through an event
/// It will either invoke the `transitionHandler` or the `errorHandler` depending on its current state
func processEvent(_ event: Event) {
stateMachine.tryEvent(event)
}
/// Registers a callback for processing state machine transitions
func addTransitionHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
stateMachine.addAnyHandler(.any => .any, handler: handler)
}
/// Registers a callback for processing state machine errors
func addErrorHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
stateMachine.addErrorHandler(handler: handler)
}
}
@@ -0,0 +1,164 @@
//
// Copyright 2021 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 SessionVerificationViewModelType = StateStoreViewModel<SessionVerificationViewState, SessionVerificationViewAction>
class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVerificationViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol
private var stateMachine: SessionVerificationStateMachine
// MARK: Public
var callback: ((SessionVerificationViewModelAction) -> Void)?
// MARK: - Setup
init(sessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol,
initialState: SessionVerificationViewState = SessionVerificationViewState()) {
self.sessionVerificationControllerProxy = sessionVerificationControllerProxy
stateMachine = SessionVerificationStateMachine()
super.init(initialViewState: initialState)
setupStateMachine()
sessionVerificationControllerProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .receivedVerificationData(let emojis):
self.stateMachine.processEvent(.didReceiveChallenge(emojis: emojis))
case .finished:
self.stateMachine.processEvent(.didAcceptChallenge)
case .cancelled:
self.stateMachine.processEvent(.didCancel)
case .failed:
self.stateMachine.processEvent(.didFail)
}
}.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SessionVerificationViewAction) async {
switch viewAction {
case .start:
stateMachine.processEvent(.requestVerification)
case .restart:
stateMachine.processEvent(.restart)
case .dismiss:
callback?(.finished)
case .cancel:
guard stateMachine.state == .initial ||
stateMachine.state == .verified ||
stateMachine.state == .cancelled else {
stateMachine.processEvent(.cancel)
return
}
callback?(.finished)
case .accept:
stateMachine.processEvent(.acceptChallenge)
case .decline:
stateMachine.processEvent(.declineChallenge)
}
}
// MARK: - Private
private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in
guard let self = self else { return }
self.state.verificationState = context.toState
switch (context.fromState, context.event, context.toState) {
case (.initial, .requestVerification, .requestingVerification):
self.requestVerification()
case (.showingChallenge, .acceptChallenge, .acceptingChallenge):
self.acceptChallenge()
case (.showingChallenge, .declineChallenge, .decliningChallenge):
self.declineChallenge()
case (_, .cancel, .cancelling):
self.cancelVerification()
default:
break
}
}
stateMachine.addErrorHandler { context in
fatalError("Failed transition with context: \(context)")
}
}
private func requestVerification() {
Task {
switch await sessionVerificationControllerProxy.requestVerification() {
case.success:
// Need to wait for the callback from the remote
break
case .failure:
stateMachine.processEvent(.didFail)
}
}
}
private func cancelVerification() {
Task {
switch await sessionVerificationControllerProxy.cancelVerification() {
case.success:
stateMachine.processEvent(.didCancel)
case .failure:
stateMachine.processEvent(.didFail)
}
}
}
private func acceptChallenge() {
Task {
switch await sessionVerificationControllerProxy.approveVerification() {
case.success:
// Need to wait for the callback from the remote
break
case .failure:
stateMachine.processEvent(.didFail)
}
}
}
private func declineChallenge() {
Task {
switch await sessionVerificationControllerProxy.declineVerification() {
case.success:
stateMachine.processEvent(.didCancel)
case .failure:
stateMachine.processEvent(.didFail)
}
}
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2021 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
@MainActor
protocol SessionVerificationViewModelProtocol {
var callback: ((SessionVerificationViewModelAction) -> Void)? { get set }
var context: SessionVerificationViewModelType.Context { get }
}
@@ -0,0 +1,204 @@
//
// Copyright 2021 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
import MatrixRustSDK
struct SessionVerificationScreen: View {
@ObservedObject var context: SessionVerificationViewModel.Context
// MARK: Views
var body: some View {
NavigationView {
VStack(spacing: 32.0) {
Text(heading)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("titleLabel")
switch context.viewState.verificationState {
case .initial:
StateIcon(systemName: "lock.shield")
Button(ElementL10n.startVerification) {
context.send(viewAction: .start)
}
.buttonStyle(.elementAction(.regular))
.accessibilityIdentifier("startButton")
case .cancelled:
StateIcon(systemName: "xmark.shield")
.accessibilityIdentifier("sessionVerificationFailedIcon")
Button(ElementL10n.globalRetry) {
context.send(viewAction: .restart)
}
.buttonStyle(.elementAction(.regular))
.accessibilityIdentifier("restartButton")
case .requestingVerification:
ProgressView()
.accessibilityIdentifier("requestingVerificationProgressView")
case .cancelling:
ProgressView()
.accessibilityIdentifier("cancellingVerificationProgressView")
case .acceptingChallenge:
ProgressView()
.accessibilityIdentifier("acceptingChallengeProgressView")
case .decliningChallenge:
ProgressView()
.accessibilityIdentifier("decliningChallengeProgressView")
case .showingChallenge(let emojis):
HStack(spacing: 8.0) {
ForEach(emojis.prefix(4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
HStack(spacing: 8.0) {
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
EmojiView(emoji: emoji)
}
}
actionButtons
case .verified:
StateIcon(systemName: "checkmark.shield")
.accessibilityIdentifier("sessionVerificationSucceededIcon")
}
Spacer()
}
.padding()
.padding(.top, 64)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(ElementL10n.verificationVerifyDevice)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(ElementL10n.done) {
context.send(viewAction: .dismiss)
}
.disabled(context.viewState.shouldDisableDismissButton)
.accessibilityIdentifier("dismissButton")
}
ToolbarItem(placement: .cancellationAction) {
Button(ElementL10n.actionCancel) {
context.send(viewAction: .cancel)
}
.disabled(context.viewState.shouldDisableCancelButton)
.accessibilityIdentifier("cancelButton")
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
// MARK: - Private
private var heading: String {
switch context.viewState.verificationState {
case .initial:
return ElementL10n.verificationOpenOtherToVerify
case .requestingVerification:
return ElementL10n.verificationRequestWaiting
case .acceptingChallenge:
return ElementL10n.verificationRequestWaiting
case .decliningChallenge:
return ElementL10n.verificationRequestWaiting
case .cancelling:
return ElementL10n.verificationRequestWaiting
case .showingChallenge:
return ElementL10n.verificationEmojiNotice
case .verified:
return ElementL10n.verificationConclusionOkSelfNotice
case .cancelled:
return ElementL10n.verificationCancelled
}
}
private var actionButtons: some View {
HStack(spacing: 16.0) {
Button(ElementL10n.verificationSasDoNotMatch) {
context.send(viewAction: .decline)
}
.buttonStyle(.elementAction(.regular, color: .red))
.accessibilityLabel("challengeDeclineButton")
Button(ElementL10n.verificationSasMatch) {
context.send(viewAction: .accept)
}
.buttonStyle(.elementAction(.regular))
.accessibilityLabel("challengeAcceptButton")
}
.padding(32.0)
}
struct EmojiView: View {
let emoji: SessionVerificationEmoji
var body: some View {
VStack(spacing: 16.0) {
Text(emoji.symbol)
.font(.largeTitle)
Text(emoji.description)
.font(.body)
}
.padding(8.0)
}
}
struct StateIcon: View {
let systemName: String
var body: some View {
Image(systemName: systemName)
.resizable()
.scaledToFit()
.foregroundColor(.element.accent)
.frame(width: 100, height: 100)
}
}
}
// MARK: - Previews
struct SessionVerification_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
Group {
sessionVerificationScreen(state: .initial)
sessionVerificationScreen(state: .requestingVerification)
sessionVerificationScreen(state: .cancelled)
sessionVerificationScreen(state: .showingChallenge(emojis: MockSessionVerificationControllerProxy.emojis))
sessionVerificationScreen(state: .verified)
}
}
static func sessionVerificationScreen(state: SessionVerificationStateMachine.State) -> some View {
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy(),
initialState: SessionVerificationViewState(verificationState: state))
return SessionVerificationScreen(context: viewModel.context)
}
}
@@ -27,6 +27,7 @@ class ClientProxy: ClientProxyProtocol {
private let client: Client
private let backgroundTaskService: BackgroundTaskServiceProtocol
private var sessionVerificationControllerProxy: SessionVerificationControllerProxy?
private(set) var rooms: [RoomProxy] = [] {
didSet {
@@ -96,11 +97,25 @@ class ClientProxy: ClientProxyProtocol {
return Data(bytes: bytes, count: bytes.count)
}
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError> {
await Task.detached {
do {
let sessionVerificationController = try self.client.getSessionVerificationController()
return .success(SessionVerificationControllerProxy(sessionVerificationController: sessionVerificationController))
} catch {
return .failure(.failedRetrievingSessionVerificationController)
}
}
.value
}
// MARK: Private
fileprivate func didReceiveSyncUpdate() {
Benchmark.logElapsedDurationForIdentifier("ClientSync", message: "Received sync update")
callbacks.send(.receivedSyncUpdate)
Task.detached {
await self.updateRooms()
}
@@ -12,11 +12,13 @@ import Combine
enum ClientProxyCallback {
case updatedRoomsList
case receivedSyncUpdate
}
enum ClientProxyError: Error {
case failedRetrievingAvatarURL
case failedRetrievingDisplayName
case failedRetrievingSessionVerificationController
}
protocol ClientProxyProtocol {
@@ -33,4 +35,6 @@ protocol ClientProxyProtocol {
func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError>
}
@@ -53,6 +53,10 @@ class RoomProxy: RoomProxyProtocol {
backwardStream = room.startLiveEventListener()
}
deinit {
room.setDelegate(delegate: nil)
}
var id: String {
room.id()
}
@@ -7,13 +7,64 @@
//
import Foundation
import Combine
class UserSession: UserSessionProtocol {
private var cancellables = Set<AnyCancellable>()
private var checkForSessionVerificationControllerCancellable: AnyCancellable?
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
private(set) var sessionVerificationController: SessionVerificationControllerProxyProtocol?
init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol) {
self.clientProxy = clientProxy
self.mediaProvider = mediaProvider
setupSessionVerificationWatchdog()
}
// MARK: - Private
private func setupSessionVerificationWatchdog() {
checkForSessionVerificationControllerCancellable = clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
if case .receivedSyncUpdate = callback {
self?.attemptSessionVerification()
}
}
}
private func attemptSessionVerification() {
Task {
switch await clientProxy.sessionVerificationControllerProxy() {
case .success(let sessionVerificationController):
tearDownSessionVerificationControllerWatchdog()
if !sessionVerificationController.isVerified {
callbacks.send(.sessionVerificationNeeded)
}
self.sessionVerificationController = sessionVerificationController
sessionVerificationController.callbacks.sink { callback in
switch callback {
case .finished:
self.callbacks.send(.didVerifySession)
default:
break
}
}.store(in: &cancellables)
case .failure(let error):
MXLog.error("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
}
}
}
private func tearDownSessionVerificationControllerWatchdog() {
checkForSessionVerificationControllerCancellable = nil
}
}
@@ -7,8 +7,18 @@
//
import Foundation
import Combine
enum UserSessionCallback {
case sessionVerificationNeeded
case didVerifySession
}
protocol UserSessionProtocol {
var clientProxy: ClientProxyProtocol { get }
var mediaProvider: MediaProviderProtocol { get }
var sessionVerificationController: SessionVerificationControllerProxyProtocol? { get }
var callbacks: PassthroughSubject<UserSessionCallback, Never> { get }
}
@@ -0,0 +1,62 @@
//
// MockSessionVerificationControllerProxy.swift
// ElementX
//
// Created by Stefan Ceriu on 07/06/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import MatrixRustSDK
import Combine
struct MockSessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol {
var callbacks = PassthroughSubject<SessionVerificationControllerProxyCallback, Never>()
var isVerified: Bool = false
func requestVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2.0) {
callbacks.send(.receivedVerificationData(Self.emojis))
}
return .success(())
}
func approveVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2.0) {
callbacks.send(.finished)
}
return .success(())
}
func declineVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2.0) {
callbacks.send(.cancelled)
}
return .success(())
}
func cancelVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2.0) {
callbacks.send(.cancelled)
}
return .success(())
}
// MARK: - Private
static var emojis: [SessionVerificationEmoji] {
[SessionVerificationEmoji(symbol: "🦋", description: "Butterfly"),
SessionVerificationEmoji(symbol: "🐘", description: "Elephant"),
SessionVerificationEmoji(symbol: "🦋", description: "Butterfly"),
SessionVerificationEmoji(symbol: "🎂", description: "Cake"),
SessionVerificationEmoji(symbol: "🎂", description: "Cake"),
SessionVerificationEmoji(symbol: "🏁", description: "Flag"),
SessionVerificationEmoji(symbol: "🌏", description: "Globe")
]
}
}
@@ -0,0 +1,124 @@
//
// SessionVerificationControllerProxy.swift
// ElementX
//
// Created by Stefan Ceriu on 06/06/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import MatrixRustSDK
import Combine
private class WeakSessionVerificationControllerProxy: SessionVerificationControllerDelegate {
private weak var proxy: SessionVerificationControllerProxy?
init(proxy: SessionVerificationControllerProxy) {
self.proxy = proxy
}
// MARK: - SessionVerificationControllerDelegate
func didReceiveVerificationData(data: [MatrixRustSDK.SessionVerificationEmoji]) {
proxy?.didReceiveData(data)
}
func didFail() {
proxy?.didFail()
}
func didCancel() {
proxy?.didCancel()
}
func didFinish() {
proxy?.didFinish()
}
}
class SessionVerificationControllerProxy: SessionVerificationControllerProxyProtocol {
private let sessionVerificationController: SessionVerificationController
init(sessionVerificationController: SessionVerificationController) {
self.sessionVerificationController = sessionVerificationController
sessionVerificationController.setDelegate(delegate: WeakSessionVerificationControllerProxy(proxy: self))
}
deinit {
sessionVerificationController.setDelegate(delegate: nil)
}
let callbacks = PassthroughSubject<SessionVerificationControllerProxyCallback, Never>()
var isVerified: Bool {
sessionVerificationController.isVerified()
}
func requestVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
await Task.detached {
do {
try self.sessionVerificationController.requestVerification()
return .success(())
} catch {
return .failure(.failedRequestingVerification)
}
}
.value
}
func approveVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
await Task.detached {
do {
try self.sessionVerificationController.approveVerification()
return .success(())
} catch {
return .failure(.failedApprovingVerification)
}
}
.value
}
func declineVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
await Task.detached {
do {
try self.sessionVerificationController.declineVerification()
return .success(())
} catch {
return .failure(.failedDecliningVerification)
}
}
.value
}
func cancelVerification() async -> Result<Void, SessionVerificationControllerProxyError> {
await Task.detached {
do {
try self.sessionVerificationController.cancelVerification()
return .success(())
} catch {
return .failure(.failedCancellingVerification)
}
}
.value
}
// MARK: - Private
fileprivate func didReceiveData(_ data: [MatrixRustSDK.SessionVerificationEmoji]) {
callbacks.send(.receivedVerificationData(data.map { emoji in
SessionVerificationEmoji(symbol: emoji.symbol(), description: emoji.description())
}))
}
fileprivate func didFail() {
callbacks.send(.failed)
}
fileprivate func didFinish() {
callbacks.send(.finished)
}
fileprivate func didCancel() {
callbacks.send(.cancelled)
}
}
@@ -0,0 +1,43 @@
//
// SessionVerificationControllerProxyProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 07/06/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import Combine
enum SessionVerificationControllerProxyError: Error {
case failedRequestingVerification
case failedApprovingVerification
case failedDecliningVerification
case failedCancellingVerification
}
enum SessionVerificationControllerProxyCallback {
case receivedVerificationData([SessionVerificationEmoji])
case finished
case cancelled
case failed
}
struct SessionVerificationEmoji: Hashable {
let symbol: String
let description: String
}
protocol SessionVerificationControllerProxyProtocol {
var callbacks: PassthroughSubject<SessionVerificationControllerProxyCallback, Never> { get }
var isVerified: Bool { get }
func requestVerification() async -> Result<Void, SessionVerificationControllerProxyError>
func approveVerification() async -> Result<Void, SessionVerificationControllerProxyError>
func declineVerification() async -> Result<Void, SessionVerificationControllerProxyError>
func cancelVerification() async -> Result<Void, SessionVerificationControllerProxyError>
}
@@ -18,7 +18,9 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
self.roomProxy.callbacks.sink { [weak self] callback in
self.roomProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
@@ -20,6 +20,7 @@ enum UITestScreenIdentifier: String {
case splash
case roomPlainNoAvatar
case roomEncryptedWithAvatar
case sessionVerification
}
extension UITestScreenIdentifier: CustomStringConvertible {
+21 -14
View File
@@ -22,13 +22,13 @@ class UITestsAppCoordinator: Coordinator {
window.tintColor = .element.accent
let screens = mockScreens()
screens.forEach { $0.coordinator.start() }
let rootView = UITestsRootView(mockScreens: screens) { id in
guard let screen = screens.first(where: { $0.id == id }) else {
fatalError()
}
screen.coordinator.start()
self.mainNavigationController.pushViewController(screen.coordinator.toPresentable(), animated: true)
}
@@ -45,9 +45,9 @@ class UITestsAppCoordinator: Coordinator {
}
@MainActor
struct MockScreen: Identifiable {
class MockScreen: Identifiable {
let id: UITestScreenIdentifier
var coordinator: Coordinator & Presentable {
lazy var coordinator: Coordinator & Presentable = {
switch id {
case .login:
let router = NavigationRouter(navigationController: ElementNavigationController())
@@ -78,17 +78,24 @@ struct MockScreen: Identifiable {
case .splash:
return SplashScreenCoordinator()
case .roomPlainNoAvatar:
let params = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
roomName: "Some room name",
roomAvatar: nil,
roomEncryptionBadge: nil)
return RoomScreenCoordinator(parameters: params)
let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
roomName: "Some room name",
roomAvatar: nil,
roomEncryptionBadge: nil)
return RoomScreenCoordinator(parameters: parameters)
case .roomEncryptedWithAvatar:
let params = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
roomName: "Some room name",
roomAvatar: Asset.Images.appLogo.image,
roomEncryptionBadge: Asset.Images.encryptionTrusted.image)
return RoomScreenCoordinator(parameters: params)
let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
roomName: "Some room name",
roomAvatar: Asset.Images.appLogo.image,
roomEncryptionBadge: Asset.Images.encryptionTrusted.image)
return RoomScreenCoordinator(parameters: parameters)
case .sessionVerification:
let parameters = SessionVerificationCoordinatorParameters(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy())
return SessionVerificationCoordinator(parameters: parameters)
}
}()
init(id: UITestScreenIdentifier) {
self.id = id
}
}