mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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 }
|
||||
}
|
||||
|
||||
+62
@@ -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")
|
||||
]
|
||||
}
|
||||
}
|
||||
+124
@@ -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)
|
||||
}
|
||||
}
|
||||
+43
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user