Initial project setup.

This commit is contained in:
Stefan Ceriu
2022-02-14 18:05:21 +02:00
parent a499570f39
commit a3fcc0f612
66 changed files with 4474 additions and 0 deletions
@@ -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)))
}
}
}
@@ -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)
}
}
@@ -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
}
}
}