mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Fixes #239 - Use new restoration token format, pass stored homeserver url to the client builder when restoring, dropping the need for a network request
- bumped the rust sdk to v1.0.17-alpha - move all UserSession related files to Services/UserSession
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension FileManager {
|
||||
/// The URL of the primary app group container.
|
||||
@objc var appGroupContainerURL: URL? {
|
||||
containerURL(forSecurityApplicationGroupIdentifier: ElementInfoPlist.appGroupIdentifier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KeychainAccess
|
||||
|
||||
class KeychainController: KeychainControllerProtocol {
|
||||
private let keychain: Keychain
|
||||
|
||||
init(identifier: String) {
|
||||
keychain = Keychain(service: identifier)
|
||||
}
|
||||
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername username: String) {
|
||||
do {
|
||||
let tokenData = try JSONEncoder().encode(restorationToken)
|
||||
try keychain.set(tokenData, key: username)
|
||||
} catch {
|
||||
MXLog.error("Failed storing user restore token with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func restorationTokenForUsername(_ username: String) -> RestorationToken? {
|
||||
do {
|
||||
guard let tokenData = try keychain.getData(username) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle the previous restoration token format as we don't want users to have to log in again
|
||||
// It will automatically be updated to the new version after login
|
||||
if let legacyRestorationToken = try? JSONDecoder().decode(LegacyRestorationToken.self, from: tokenData) {
|
||||
return .init(session: .init(accessToken: legacyRestorationToken.session.accessToken,
|
||||
refreshToken: nil,
|
||||
userId: legacyRestorationToken.session.userId,
|
||||
deviceId: legacyRestorationToken.session.deviceId,
|
||||
homeserverUrl: legacyRestorationToken.homeURL,
|
||||
isSoftLogout: legacyRestorationToken.isSoftLogout ?? false))
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(RestorationToken.self, from: tokenData)
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving user restore token")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func restorationTokens() -> [KeychainCredentials] {
|
||||
keychain.allKeys().compactMap { username in
|
||||
guard let restorationToken = restorationTokenForUsername(username) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return KeychainCredentials(userID: username, restorationToken: restorationToken)
|
||||
}
|
||||
}
|
||||
|
||||
func removeRestorationTokenForUsername(_ username: String) {
|
||||
do {
|
||||
try keychain.remove(username)
|
||||
} catch {
|
||||
MXLog.error("Failed removing restore token with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllRestorationTokens() {
|
||||
do {
|
||||
try keychain.removeAll()
|
||||
} catch {
|
||||
MXLog.error("Failed removing all tokens")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct KeychainCredentials {
|
||||
let userID: String
|
||||
let restorationToken: RestorationToken
|
||||
}
|
||||
|
||||
protocol KeychainControllerProtocol {
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername: String)
|
||||
func restorationTokenForUsername(_ username: String) -> RestorationToken?
|
||||
func restorationTokens() -> [KeychainCredentials]
|
||||
func removeRestorationTokenForUsername(_ username: String)
|
||||
func removeAllRestorationTokens()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
struct RestorationToken: Codable, Equatable {
|
||||
let session: MatrixRustSDK.Session
|
||||
}
|
||||
|
||||
extension MatrixRustSDK.Session: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self = .init(accessToken: try container.decode(String.self, forKey: .accessToken),
|
||||
refreshToken: try container.decodeIfPresent(String.self, forKey: .refreshToken),
|
||||
userId: try container.decode(String.self, forKey: .userId),
|
||||
deviceId: try container.decode(String.self, forKey: .deviceId),
|
||||
homeserverUrl: try container.decode(String.self, forKey: .homeserverUrl),
|
||||
isSoftLogout: try container.decode(Bool.self, forKey: .isSoftLogout))
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(accessToken, forKey: .accessToken)
|
||||
try container.encode(refreshToken, forKey: .refreshToken)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(deviceId, forKey: .deviceId)
|
||||
try container.encode(homeserverUrl, forKey: .homeserverUrl)
|
||||
try container.encode(isSoftLogout, forKey: .isSoftLogout)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken, refreshToken, userId, deviceId, homeserverUrl, isSoftLogout
|
||||
}
|
||||
}
|
||||
|
||||
#warning("Remove this in a couple of releases - sceriu 03.11.2022")
|
||||
struct LegacyRestorationToken: Decodable {
|
||||
let isGuest: Bool?
|
||||
let isSoftLogout: Bool?
|
||||
let homeURL: String
|
||||
let session: Session
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case isGuest = "is_guest"
|
||||
case isSoftLogout = "is_soft_logout"
|
||||
case homeURL = "homeurl"
|
||||
case session
|
||||
}
|
||||
|
||||
struct Session: Decodable {
|
||||
let accessToken: String
|
||||
let userId: String
|
||||
let deviceId: String
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case userId = "user_id"
|
||||
case deviceId = "device_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum UserSessionFlowCoordinatorAction {
|
||||
case signOut
|
||||
}
|
||||
|
||||
class UserSessionFlowCoordinator: Coordinator {
|
||||
private let stateMachine: UserSessionFlowCoordinatorStateMachine
|
||||
|
||||
private let userSession: UserSessionProtocol
|
||||
private let navigationRouter: NavigationRouterType
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
|
||||
|
||||
init(userSession: UserSessionProtocol, navigationRouter: NavigationRouterType, bugReportService: BugReportServiceProtocol) {
|
||||
stateMachine = UserSessionFlowCoordinatorStateMachine()
|
||||
self.userSession = userSession
|
||||
self.navigationRouter = navigationRouter
|
||||
self.bugReportService = bugReportService
|
||||
|
||||
setupStateMachine()
|
||||
startObservingApplicationState()
|
||||
}
|
||||
|
||||
func start() {
|
||||
stateMachine.processEvent(.start)
|
||||
}
|
||||
|
||||
func stop() { }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
guard let self else { return }
|
||||
|
||||
switch (context.fromState, context.event, context.toState) {
|
||||
case (.initial, .start, .homeScreen):
|
||||
self.presentHomeScreen()
|
||||
|
||||
case(.homeScreen, .showRoomScreen, .roomScreen(let roomId)):
|
||||
self.presentRoomWithIdentifier(roomId)
|
||||
case(.roomScreen(let roomId), .dismissedRoomScreen, .homeScreen):
|
||||
self.tearDownDismissedRoomScreen(roomId)
|
||||
|
||||
case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen):
|
||||
self.presentSessionVerification()
|
||||
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen):
|
||||
self.tearDownDismissedSessionVerificationScreen()
|
||||
|
||||
case (.homeScreen, .showSettingsScreen, .settingsScreen):
|
||||
self.presentSettingsScreen()
|
||||
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
|
||||
self.dismissSettingsScreen()
|
||||
|
||||
case (.homeScreen, .feedbackScreen, .feedbackScreen):
|
||||
self.presentFeedbackScreen()
|
||||
case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen):
|
||||
self.dismissFeedbackScreen()
|
||||
|
||||
case (_, .resignActive, .suspended):
|
||||
self.pause()
|
||||
case (_, .becomeActive, _):
|
||||
self.resume()
|
||||
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservingApplicationState() {
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
private func presentHomeScreen() {
|
||||
userSession.clientProxy.startSync()
|
||||
|
||||
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
let coordinator = HomeScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .presentRoomScreen(let roomIdentifier):
|
||||
self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier))
|
||||
case .presentSettingsScreen:
|
||||
self.stateMachine.processEvent(.showSettingsScreen)
|
||||
case .presentFeedbackScreen:
|
||||
self.stateMachine.processEvent(.feedbackScreen)
|
||||
case .presentSessionVerificationScreen:
|
||||
self.stateMachine.processEvent(.showSessionVerificationScreen)
|
||||
case .signOut:
|
||||
self.callback?(.signOut)
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
navigationRouter.setRootModule(coordinator)
|
||||
|
||||
if bugReportService.crashedLastRun {
|
||||
showCrashPopup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Rooms
|
||||
|
||||
private func presentRoomWithIdentifier(_ roomIdentifier: String) {
|
||||
Task { @MainActor in
|
||||
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
|
||||
MXLog.error("Invalid room identifier: \(roomIdentifier)")
|
||||
return
|
||||
}
|
||||
let userId = userSession.clientProxy.userIdentifier
|
||||
|
||||
let timelineItemFactory = RoomTimelineItemFactory(userID: userId,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
roomProxy: roomProxy,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
|
||||
let timelineController = RoomTimelineController(userId: userId,
|
||||
roomId: roomIdentifier,
|
||||
timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
roomProxy: roomProxy)
|
||||
|
||||
let parameters = RoomScreenCoordinatorParameters(timelineController: timelineController,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
roomName: roomProxy.displayName ?? roomProxy.name,
|
||||
roomAvatarUrl: roomProxy.avatarURL)
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stateMachine.processEvent(.dismissedRoomScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDownDismissedRoomScreen(_ roomId: String) {
|
||||
guard let coordinator = childCoordinators.last as? RoomScreenCoordinator else {
|
||||
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
|
||||
}
|
||||
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
|
||||
private func presentSettingsScreen() {
|
||||
let navController = ElementNavigationController()
|
||||
let newNavigationRouter = NavigationRouter(navigationController: navController)
|
||||
|
||||
let parameters = SettingsCoordinatorParameters(navigationRouter: newNavigationRouter,
|
||||
userSession: userSession,
|
||||
bugReportService: bugReportService)
|
||||
let coordinator = SettingsCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self.dismissSettingsScreen()
|
||||
case .logout:
|
||||
self.dismissSettingsScreen()
|
||||
self.callback?(.signOut)
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
navController.viewControllers = [coordinator.toPresentable()]
|
||||
navigationRouter.present(navController, animated: true)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func dismissSettingsScreen() {
|
||||
MXLog.debug("dismissSettingsScreen")
|
||||
|
||||
guard let coordinator = childCoordinators.first(where: { $0 is SettingsCoordinator }) else {
|
||||
return
|
||||
}
|
||||
|
||||
navigationRouter.dismissModule()
|
||||
remove(childCoordinator: coordinator)
|
||||
|
||||
stateMachine.processEvent(.dismissedSettingsScreen)
|
||||
}
|
||||
|
||||
// MARK: Session verification
|
||||
|
||||
private func presentSessionVerification() {
|
||||
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: Bug reporting
|
||||
|
||||
private func showCrashPopup() {
|
||||
let alert = UIAlertController(title: nil,
|
||||
message: ElementL10n.sendBugReportAppCrashed,
|
||||
preferredStyle: .alert)
|
||||
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.no, style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.yes, style: .default) { [weak self] _ in
|
||||
self?.stateMachine.processEvent(.feedbackScreen)
|
||||
})
|
||||
|
||||
navigationRouter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentFeedbackScreen(for image: UIImage? = nil) {
|
||||
let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService,
|
||||
screenshot: image)
|
||||
let coordinator = BugReportCoordinator(parameters: parameters)
|
||||
coordinator.completion = { [weak self] in
|
||||
self?.stateMachine.processEvent(.dismissedFeedbackScreen)
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
let navController = ElementNavigationController(rootViewController: coordinator.toPresentable())
|
||||
navController.navigationBar.topItem?.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(handleFeedbackScreenCancellation))
|
||||
navController.isModalInPresentation = true
|
||||
navigationRouter.present(navController, animated: true)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleFeedbackScreenCancellation() {
|
||||
stateMachine.processEvent(.dismissedFeedbackScreen)
|
||||
}
|
||||
|
||||
private func dismissFeedbackScreen() {
|
||||
guard let coordinator = childCoordinators.first(where: { $0 is BugReportCoordinator }) else {
|
||||
return
|
||||
}
|
||||
|
||||
navigationRouter.dismissModule()
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
// MARK: - Application State
|
||||
|
||||
private func pause() {
|
||||
userSession.clientProxy.stopSync()
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
userSession.clientProxy.startSync()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func applicationWillResignActive() {
|
||||
stateMachine.processEvent(.resignActive)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func applicationDidBecomeActive() {
|
||||
stateMachine.processEvent(.becomeActive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftState
|
||||
|
||||
class UserSessionFlowCoordinatorStateMachine {
|
||||
/// States the AppCoordinator can find itself in
|
||||
enum State: StateType {
|
||||
/// The initial state, used before the coordinator starts
|
||||
case initial
|
||||
|
||||
/// Showing the home screen
|
||||
case homeScreen
|
||||
|
||||
/// 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 session verification flows
|
||||
case feedbackScreen
|
||||
|
||||
/// Showing the settings screen
|
||||
case settingsScreen
|
||||
|
||||
/// Application has been suspended
|
||||
case suspended
|
||||
}
|
||||
|
||||
/// Events that can be triggered on the AppCoordinator state machine
|
||||
enum Event: EventType {
|
||||
/// Start the user session flows
|
||||
case start
|
||||
|
||||
/// Request presentation for a particular room
|
||||
/// - Parameter roomId:the room identifier
|
||||
case showRoomScreen(roomId: String)
|
||||
/// The room screen has been dismissed
|
||||
case dismissedRoomScreen
|
||||
|
||||
/// Request presentation of the settings screen
|
||||
case showSettingsScreen
|
||||
/// The settings screen has been dismissed
|
||||
case dismissedSettingsScreen
|
||||
|
||||
/// Request presentation of the feedback screen
|
||||
case feedbackScreen
|
||||
/// The feedback screen has been dismissed
|
||||
case dismissedFeedbackScreen
|
||||
|
||||
/// Request the start of the session verification flow
|
||||
case showSessionVerificationScreen
|
||||
/// Session verification has finished
|
||||
case dismissedSessionVerificationScreen
|
||||
|
||||
/// Application goes into inactive state
|
||||
case resignActive
|
||||
/// Application goes into active state
|
||||
case becomeActive
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
private var stateBeforeSuspension: State?
|
||||
|
||||
init() {
|
||||
stateMachine = StateMachine(state: .initial)
|
||||
configure()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func configure() {
|
||||
stateMachine.addRoutes(event: .start, transitions: [.initial => .homeScreen])
|
||||
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
switch (event, fromState) {
|
||||
case (.showRoomScreen(let roomId), .homeScreen):
|
||||
return .roomScreen(roomId: roomId)
|
||||
case (.dismissedRoomScreen, .roomScreen):
|
||||
return .homeScreen
|
||||
|
||||
case (.showSettingsScreen, .homeScreen):
|
||||
return .settingsScreen
|
||||
case (.dismissedSettingsScreen, .settingsScreen):
|
||||
return .homeScreen
|
||||
|
||||
case (.feedbackScreen, .homeScreen):
|
||||
return .feedbackScreen
|
||||
case (.dismissedFeedbackScreen, .feedbackScreen):
|
||||
return .homeScreen
|
||||
|
||||
case (.showSessionVerificationScreen, .homeScreen):
|
||||
return .sessionVerificationScreen
|
||||
case (.dismissedSessionVerificationScreen, .sessionVerificationScreen):
|
||||
return .homeScreen
|
||||
|
||||
case (.resignActive, _):
|
||||
self.stateBeforeSuspension = fromState
|
||||
return .suspended
|
||||
case (.becomeActive, _):
|
||||
// Cannot become active if not previously suspended
|
||||
// Happens when the app is backgrounded before the session is setup
|
||||
guard let previousState = self.stateBeforeSuspension else {
|
||||
return self.stateMachine.state
|
||||
}
|
||||
|
||||
return previousState
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
addTransitionHandler { context in
|
||||
if let event = context.event {
|
||||
MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(event)`")
|
||||
} else {
|
||||
MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,155 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
import MatrixRustSDK
|
||||
|
||||
class UserSessionStore: UserSessionStoreProtocol {
|
||||
private let keychainController: KeychainControllerProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
|
||||
/// Whether or not there are sessions in the store.
|
||||
var hasSessions: Bool { !keychainController.restorationTokens().isEmpty }
|
||||
|
||||
/// The base directory where all session data is stored.
|
||||
private(set) lazy var baseDirectory: URL = {
|
||||
guard let appGroupContainerURL = FileManager.default.appGroupContainerURL else {
|
||||
fatalError("Should always be able to retrieve the container directory")
|
||||
}
|
||||
|
||||
let url = appGroupContainerURL
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Caches", isDirectory: true)
|
||||
.appendingPathComponent("Sessions", isDirectory: true)
|
||||
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil)
|
||||
|
||||
MXLog.debug("Setup base directory at: \(url)")
|
||||
|
||||
return url
|
||||
}()
|
||||
|
||||
init(bundleIdentifier: String, backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
keychainController = KeychainController(identifier: bundleIdentifier)
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
}
|
||||
|
||||
func restoreUserSession() async -> Result<UserSession, UserSessionStoreError> {
|
||||
let availableCredentials = keychainController.restorationTokens()
|
||||
|
||||
guard let credentials = availableCredentials.first else {
|
||||
return .failure(.missingCredentials)
|
||||
}
|
||||
|
||||
switch await restorePreviousLogin(credentials) {
|
||||
case .success(let clientProxy):
|
||||
return .success(UserSession(clientProxy: clientProxy,
|
||||
mediaProvider: MediaProvider(clientProxy: clientProxy,
|
||||
imageCache: .onlyInMemory,
|
||||
backgroundTaskService: backgroundTaskService)))
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed restoring login with error: \(error)")
|
||||
|
||||
// On any restoration failure reset the token and restart
|
||||
keychainController.removeAllRestorationTokens()
|
||||
deleteSessionDirectory(for: credentials.userID)
|
||||
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
func userSession(for client: Client) async -> Result<UserSession, UserSessionStoreError> {
|
||||
switch await setupProxyForClient(client) {
|
||||
case .success(let clientProxy):
|
||||
return .success(UserSession(clientProxy: clientProxy,
|
||||
mediaProvider: MediaProvider(clientProxy: clientProxy,
|
||||
imageCache: .onlyInMemory,
|
||||
backgroundTaskService: backgroundTaskService)))
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed creating user session with error: \(error)")
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshRestorationToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError> {
|
||||
guard let restorationToken = userSession.clientProxy.restorationToken else {
|
||||
return .failure(.failedRefreshingRestoreToken)
|
||||
}
|
||||
|
||||
keychainController.setRestorationToken(restorationToken, forUsername: userSession.clientProxy.userIdentifier)
|
||||
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func logout(userSession: UserSessionProtocol) {
|
||||
let userID = userSession.clientProxy.userIdentifier
|
||||
keychainController.removeRestorationTokenForUsername(userID)
|
||||
deleteSessionDirectory(for: userID)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func restorePreviousLogin(_ credentials: KeychainCredentials) async -> Result<ClientProxyProtocol, UserSessionStoreError> {
|
||||
Benchmark.startTrackingForIdentifier("Login", message: "Started restoring previous login")
|
||||
|
||||
let builder = ClientBuilder()
|
||||
.basePath(path: baseDirectory.path)
|
||||
.username(username: credentials.userID)
|
||||
.homeserverUrl(url: credentials.restorationToken.session.homeserverUrl)
|
||||
.userAgent(userAgent: UserAgentBuilder.makeASCIIUserAgent() ?? "unknown")
|
||||
|
||||
do {
|
||||
let client: Client = try await Task.dispatch(on: .global()) {
|
||||
let client = try builder.build()
|
||||
try client.restoreSession(session: credentials.restorationToken.session)
|
||||
return client
|
||||
}
|
||||
return await setupProxyForClient(client)
|
||||
} catch {
|
||||
MXLog.error("Failed restoring login with error: \(error)")
|
||||
return .failure(.failedRestoringLogin)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupProxyForClient(_ client: Client) async -> Result<ClientProxyProtocol, UserSessionStoreError> {
|
||||
do {
|
||||
let session = try client.session()
|
||||
let userId = try client.userId()
|
||||
|
||||
keychainController.setRestorationToken(RestorationToken(session: session), forUsername: userId)
|
||||
} catch {
|
||||
MXLog.error("Failed setting up user session with error: \(error)")
|
||||
return .failure(.failedSettingUpSession)
|
||||
}
|
||||
|
||||
let clientProxy = await ClientProxy(client: client, backgroundTaskService: backgroundTaskService)
|
||||
|
||||
return .success(clientProxy)
|
||||
}
|
||||
|
||||
private func deleteSessionDirectory(for userID: String) {
|
||||
// Rust sanitises the user ID replacing invalid characters with an _
|
||||
let sanitisedUserID = userID.replacingOccurrences(of: ":", with: "_")
|
||||
let url = baseDirectory.appendingPathComponent(sanitisedUserID)
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
MXLog.failure("Failed deleting the session data: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum UserSessionStoreError: Error {
|
||||
case missingCredentials
|
||||
case failedRestoringLogin
|
||||
case failedSettingUpSession
|
||||
case failedRefreshingRestoreToken
|
||||
}
|
||||
|
||||
protocol UserSessionStoreProtocol {
|
||||
/// Whether or not there are sessions in the store.
|
||||
var hasSessions: Bool { get }
|
||||
|
||||
/// Returns the location to store user data for a particular username.
|
||||
var baseDirectory: URL { get }
|
||||
|
||||
/// Restores an existing user session.
|
||||
func restoreUserSession() async -> Result<UserSession, UserSessionStoreError>
|
||||
|
||||
/// Creates a user session for a new client from the SDK.
|
||||
func userSession(for client: Client) async -> Result<UserSession, UserSessionStoreError>
|
||||
|
||||
/// Refresh the restore token of the client for a given session.
|
||||
func refreshRestorationToken(for userSession: UserSessionProtocol) -> Result<Void, UserSessionStoreError>
|
||||
|
||||
/// Logs out of the specified session.
|
||||
func logout(userSession: UserSessionProtocol)
|
||||
}
|
||||
Reference in New Issue
Block a user