mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Initial project setup.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// AuthenticationCoordinator.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 11.02.2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum AuthenticationCoordinatorError: Error {
|
||||
case failedLoggingIn
|
||||
case failedRestoringLogin
|
||||
case failedSettingUpSession
|
||||
}
|
||||
|
||||
protocol AuthenticationCoordinatorDelegate: AnyObject {
|
||||
func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator)
|
||||
|
||||
func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator)
|
||||
|
||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator,
|
||||
didFailWithError error: AuthenticationCoordinatorError)
|
||||
}
|
||||
|
||||
class AuthenticationCoordinator: Coordinator {
|
||||
|
||||
private let keychainController: KeychainControllerProtocol
|
||||
private let navigationRouter: NavigationRouter
|
||||
|
||||
private(set) var userSession: UserSession?
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
weak var delegate: AuthenticationCoordinatorDelegate?
|
||||
|
||||
init(keychainController: KeychainControllerProtocol,
|
||||
navigationRouter: NavigationRouter) {
|
||||
self.keychainController = keychainController
|
||||
self.navigationRouter = navigationRouter
|
||||
}
|
||||
|
||||
func start() {
|
||||
let availableRestoreTokens = keychainController.restoreTokens()
|
||||
|
||||
guard let usernameTokenTuple = availableRestoreTokens.first else {
|
||||
startNewLoginFlow()
|
||||
return
|
||||
}
|
||||
|
||||
restorePreviousLogin(usernameTokenTuple)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func startNewLoginFlow() {
|
||||
let parameters = LoginScreenCoordinatorParameters()
|
||||
let coordinator = LoginScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.completion = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .login(let result):
|
||||
do {
|
||||
self.setupUserSessionForClient(try loginNewClient(basePath: self.baseDirectoryPathForUsername(result.username),
|
||||
username: result.username,
|
||||
password: result.password))
|
||||
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.navigationRouter.dismissModule()
|
||||
} catch {
|
||||
self.delegate?.authenticationCoordinator(self, didFailWithError: .failedLoggingIn)
|
||||
MXLog.error("Failed logging in user with error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
navigationRouter.present(coordinator)
|
||||
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String)) {
|
||||
do {
|
||||
setupUserSessionForClient(try loginWithToken(basePath: baseDirectoryPathForUsername(usernameTokenTuple.username),
|
||||
restoreToken: usernameTokenTuple.token))
|
||||
} catch {
|
||||
delegate?.authenticationCoordinator(self, didFailWithError: .failedRestoringLogin)
|
||||
MXLog.error("Failed restoring login with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUserSessionForClient(_ client: Client) {
|
||||
|
||||
do {
|
||||
let restoreToken = try client.restoreToken()
|
||||
let userId = try client.userId()
|
||||
|
||||
keychainController.setRestoreToken(restoreToken, forUsername: userId)
|
||||
} catch {
|
||||
delegate?.authenticationCoordinator(self, didFailWithError: .failedSettingUpSession)
|
||||
MXLog.error("Failed setting up user session with error: \(error)")
|
||||
}
|
||||
|
||||
userSession = UserSession(client: client)
|
||||
delegate?.authenticationCoordinatorDidSetupUserSession(self)
|
||||
}
|
||||
|
||||
private func baseDirectoryPathForUsername(_ username: String) -> String {
|
||||
guard var url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
fatalError("Should always be able to retrieve the caches directory")
|
||||
}
|
||||
|
||||
url = url.appendingPathComponent(username)
|
||||
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil)
|
||||
|
||||
return url.path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// KeychainController.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 14.02.2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KeychainAccess
|
||||
|
||||
class KeychainController: KeychainControllerProtocol {
|
||||
|
||||
struct Constants {
|
||||
static let restoreTokenGroupKey = "restoreTokens"
|
||||
}
|
||||
|
||||
private let keychain: Keychain
|
||||
|
||||
init(identifier: String) {
|
||||
keychain = Keychain(service: identifier)
|
||||
}
|
||||
|
||||
func setRestoreToken(_ token: String, forUsername username: String) {
|
||||
do {
|
||||
try keychain.set(token, key: username)
|
||||
} catch {
|
||||
MXLog.error("Failed storing user restore token")
|
||||
}
|
||||
}
|
||||
|
||||
func restoreTokenForUsername(_ username: String) -> String? {
|
||||
do {
|
||||
return try keychain.get(username)
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving user restore token")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func restoreTokens() -> [(username: String, token: String)] {
|
||||
keychain.allKeys().compactMap { username in
|
||||
guard let token = restoreTokenForUsername(username) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (username, token)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllTokens() {
|
||||
do {
|
||||
try keychain.removeAll()
|
||||
} catch {
|
||||
MXLog.error("Failed removing all tokens")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// KeychainControllerProtocol.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 14.02.2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol KeychainControllerProtocol {
|
||||
func setRestoreToken(_ token: String, forUsername username: String)
|
||||
func restoreTokenForUsername(_ username: String) -> String?
|
||||
func restoreTokens() -> [(username: String, token: String)]
|
||||
func removeAllTokens()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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 LoginScreenCoordinatorParameters {
|
||||
|
||||
}
|
||||
|
||||
final class LoginScreenCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: LoginScreenCoordinatorParameters
|
||||
private let loginScreenHostingController: UIViewController
|
||||
private var loginScreenViewModel: LoginScreenViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var completion: ((LoginScreenViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
init(parameters: LoginScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
loginScreenViewModel = LoginScreenViewModel()
|
||||
let view = LoginScreen(context: loginScreenViewModel.context)
|
||||
|
||||
loginScreenHostingController = UIHostingController(rootView: view)
|
||||
loginScreenHostingController.isModalInPresentation = true
|
||||
|
||||
loginScreenViewModel.completion = { [weak self] result in
|
||||
MXLog.debug("[LoginScreenCoordinator] LoginScreenViewModel did complete with result: \(result).")
|
||||
guard let self = self else { return }
|
||||
self.completion?(result)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
return self.loginScreenHostingController
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
enum LoginScreenViewModelResult {
|
||||
case login((username: String, password: String))
|
||||
}
|
||||
|
||||
enum LoginScreenViewAction {
|
||||
case login
|
||||
}
|
||||
|
||||
struct LoginScreenViewState: BindableState {
|
||||
var bindings: LoginScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct LoginScreenViewStateBindings {
|
||||
var username: String
|
||||
var password: String
|
||||
}
|
||||
|
||||
struct LoginScreenErrorAlertInfo: Identifiable {
|
||||
enum AlertType {
|
||||
case genericFailure
|
||||
}
|
||||
|
||||
let id: AlertType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@available(iOS 14, *)
|
||||
typealias LoginScreenViewModelType = StateStoreViewModel<LoginScreenViewState,
|
||||
Never,
|
||||
LoginScreenViewAction>
|
||||
@available(iOS 14, *)
|
||||
class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var completion: ((LoginScreenViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init() {
|
||||
super.init(initialViewState: LoginScreenViewState(bindings: LoginScreenViewStateBindings(username: "@stefan.ceriu-element01:matrix.org",
|
||||
password: "radeon")))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: LoginScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .login:
|
||||
completion?(.login((username: context.username, password: context.password)))
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
protocol LoginScreenViewModelProtocol {
|
||||
|
||||
var completion: ((LoginScreenViewModelResult) -> Void)? { get set }
|
||||
@available(iOS 14, *)
|
||||
var context: LoginScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// 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 XCTest
|
||||
import RiotSwiftUI
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
class LoginScreenUITests: MockScreenTest {
|
||||
|
||||
override class var screenType: MockScreenState.Type {
|
||||
return MockLoginScreenScreenState.self
|
||||
}
|
||||
|
||||
override class func createTest() -> MockScreenTest {
|
||||
return LoginScreenUITests(selector: #selector(verifyLoginScreenScreen))
|
||||
}
|
||||
|
||||
func verifyLoginScreenScreen() throws {
|
||||
guard let screenState = screenState as? MockLoginScreenScreenState else { fatalError("no screen") }
|
||||
switch screenState {
|
||||
case .promptType(let promptType):
|
||||
verifyLoginScreenPromptType(promptType: promptType)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyLoginScreenPromptType(promptType: LoginScreenPromptType) {
|
||||
let title = app.staticTexts["title"]
|
||||
XCTAssert(title.exists)
|
||||
XCTAssertEqual(title.label, promptType.title)
|
||||
}
|
||||
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// 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 XCTest
|
||||
|
||||
@testable import ElementX
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
class LoginScreenViewModelTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
|
||||
}
|
||||
|
||||
func testInitialState() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct LoginScreen: View {
|
||||
|
||||
@ObservedObject var context: LoginScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
TextField("Username", text: $context.username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Enter a password", text: $context.password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button { context.send(viewAction: .login) } label: {
|
||||
Text("Login")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal, 50)
|
||||
}
|
||||
.padding(.horizontal, 8.0)
|
||||
.navigationTitle("Login")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct LoginScreen_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let viewModel = LoginScreenViewModel()
|
||||
LoginScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// UserSession.swift
|
||||
// ElementX
|
||||
//
|
||||
// Created by Stefan Ceriu on 14.02.2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
class UserSession {
|
||||
|
||||
private let client: Client
|
||||
|
||||
init(client: Client) {
|
||||
self.client = client
|
||||
|
||||
if !client.hasFirstSynced() {
|
||||
MXLog.info("Started initial sync")
|
||||
client.startSync()
|
||||
MXLog.info("Finished intial sync")
|
||||
}
|
||||
}
|
||||
|
||||
func roomList() -> [RoomModel] {
|
||||
client.conversations().compactMap { room in
|
||||
do {
|
||||
return RoomModel(displayName: try room.displayName())
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving room info with error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String? {
|
||||
do {
|
||||
return try client.displayName()
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving room info with error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user