Merge branch 'develop' into johannes/session-name-trumps-device-type-name

This commit is contained in:
Johannes Marbach
2022-10-07 13:27:16 +02:00
138 changed files with 5272 additions and 258 deletions
@@ -24,6 +24,8 @@ struct AuthenticationHomeserverViewData: Equatable {
let showLoginForm: Bool
/// Whether or not to display the username and password text fields during registration.
let showRegistrationForm: Bool
/// Whether or not to display the QR login button during login.
let showQRLogin: Bool
/// The supported SSO login options.
let ssoIdentityProviders: [SSOIdentityProvider]
}
@@ -36,6 +38,7 @@ extension AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "matrix.org",
showLoginForm: true,
showRegistrationForm: true,
showQRLogin: false,
ssoIdentityProviders: [
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
@@ -50,6 +53,7 @@ extension AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "example.com",
showLoginForm: true,
showRegistrationForm: true,
showQRLogin: false,
ssoIdentityProviders: [])
}
@@ -58,6 +62,7 @@ extension AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "company.com",
showLoginForm: false,
showRegistrationForm: false,
showQRLogin: false,
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
}
@@ -66,6 +71,7 @@ extension AuthenticationHomeserverViewData {
AuthenticationHomeserverViewData(address: "company.com",
showLoginForm: false,
showRegistrationForm: false,
showQRLogin: false,
ssoIdentityProviders: [])
}
}
@@ -48,6 +48,10 @@ protocol AuthenticationRestClient: AnyObject {
func forgetPassword(for email: String, clientSecret: String, sendAttempt: UInt) async throws -> String
func resetPassword(parameters: CheckResetPasswordParameters) async throws
func resetPassword(parameters: [String: Any]) async throws
// MARK: Versions
func supportedMatrixVersions() async throws -> MXMatrixVersions
}
extension MXRestClient: AuthenticationRestClient { }
@@ -259,10 +259,14 @@ class AuthenticationService: NSObject {
}
let loginFlow = try await getLoginFlowResult(client: client)
let supportsQRLogin = try await QRLoginService(client: client,
mode: .notAuthenticated).isServiceAvailable()
let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress,
addressFromUser: homeserverAddress,
preferredLoginMode: loginFlow.loginMode)
preferredLoginMode: loginFlow.loginMode,
supportsQRLogin: supportsQRLogin)
return (client, homeserver)
}
@@ -52,6 +52,9 @@ struct AuthenticationState {
/// The preferred login mode for the server
var preferredLoginMode: LoginMode = .unknown
/// Flag indicating whether the homeserver supports logging in via a QR code.
var supportsQRLogin = false
/// The response returned when querying the homeserver for registration flows.
var registrationFlow: RegistrationResult?
@@ -67,6 +70,7 @@ struct AuthenticationState {
AuthenticationHomeserverViewData(address: displayableAddress,
showLoginForm: preferredLoginMode.supportsPasswordFlow,
showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback,
showQRLogin: supportsQRLogin,
ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? [])
}
@@ -31,6 +31,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible {
case continueWithSSO(SSOIdentityProvider)
/// Continue using the fallback page
case fallback
/// Continue with QR login
case qrLogin
/// A string representation of the result, ignoring any associated values that could leak PII.
var description: String {
@@ -47,6 +49,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible {
return "continueWithSSO: \(provider)"
case .fallback:
return "fallback"
case .qrLogin:
return "qrLogin"
}
}
}
@@ -99,6 +103,8 @@ enum AuthenticationLoginViewAction {
case fallback
/// Continue using the supplied SSO provider.
case continueWithSSO(SSOIdentityProvider)
/// Continue using QR login
case qrLogin
}
enum AuthenticationLoginErrorType: Hashable {
@@ -50,6 +50,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica
Task { await callback?(.fallback) }
case .continueWithSSO(let provider):
Task { await callback?(.continueWithSSO(provider)) }
case .qrLogin:
Task { await callback?(.qrLogin) }
}
}
@@ -126,6 +126,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable {
self.callback?(.continueWithSSO(identityProvider))
case .fallback:
self.callback?(.fallback)
case .qrLogin:
self.showQRLoginScreen()
}
}
}
@@ -282,6 +284,28 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable {
navigationRouter.present(modalRouter, animated: true)
}
/// Shows the QR login screen.
@MainActor private func showQRLoginScreen() {
MXLog.debug("[AuthenticationLoginCoordinator] showQRLoginScreen")
let service = QRLoginService(client: parameters.authenticationService.client,
mode: .notAuthenticated)
let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: service)
let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Updates the view model to reflect any changes made to the homeserver.
@MainActor private func updateViewModel() {
@@ -50,6 +50,10 @@ struct AuthenticationLoginScreen: View {
if viewModel.viewState.homeserver.showLoginForm {
loginForm
}
if viewModel.viewState.homeserver.showQRLogin {
qrLoginButton
}
if viewModel.viewState.homeserver.showLoginForm, viewModel.viewState.showSSOButtons {
Text(VectorL10n.or)
@@ -129,6 +133,16 @@ struct AuthenticationLoginScreen: View {
.accessibilityIdentifier("nextButton")
}
}
/// A QR login button that can be used for login.
var qrLoginButton: some View {
Button(action: qrLogin) {
Text(VectorL10n.authenticationLoginWithQr)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.padding(.vertical)
.accessibilityIdentifier("qrLoginButton")
}
/// A list of SSO buttons that can be used for login.
var ssoButtons: some View {
@@ -174,6 +188,11 @@ struct AuthenticationLoginScreen: View {
func fallback() {
viewModel.send(viewAction: .fallback)
}
/// Sends the `qrLogin` view action.
func qrLogin() {
viewModel.send(viewAction: .qrLogin)
}
}
// MARK: - Previews
@@ -0,0 +1,39 @@
//
// 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 QRLoginCode: Codable {
var user: String?
var initiator: QRLoginDataInitiatorDevice?
var rendezvous: QRLoginRendezvous?
}
enum QRLoginDataInitiatorDevice: String, Codable {
case new = "new_device"
case existing = "existing_device"
}
struct QRLoginRendezvous: Codable {
var transport: QRLoginRendezvousTransportDetails
var algorithm: String?
var key: String?
}
struct QRLoginRendezvousTransportDetails: Codable {
var type: String
var uri: String?
}
@@ -0,0 +1,184 @@
//
// 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 AVFoundation
import Combine
import Foundation
import MatrixSDK
import SwiftUI
import ZXingObjC
// MARK: - QRLoginService
class QRLoginService: NSObject, QRLoginServiceProtocol {
private let client: AuthenticationRestClient
private var isCameraReady = false
private lazy var zxCapture = ZXCapture()
private let cameraAccessManager = CameraAccessManager()
init(client: AuthenticationRestClient,
mode: QRLoginServiceMode,
state: QRLoginServiceState = .initial) {
self.client = client
self.mode = mode
self.state = state
super.init()
}
// MARK: QRLoginServiceProtocol
let mode: QRLoginServiceMode
var state: QRLoginServiceState {
didSet {
if state != oldValue {
callbacks.send(.didUpdateState)
}
}
}
let callbacks = PassthroughSubject<QRLoginServiceCallback, Never>()
func isServiceAvailable() async throws -> Bool {
guard BuildSettings.enableQRLogin else {
return false
}
return try await client.supportedMatrixVersions().supportsQRLogin
}
func generateQRCode() async throws -> QRLoginCode {
let transport = QRLoginRendezvousTransportDetails(type: "http.v1",
uri: "")
let rendezvous = QRLoginRendezvous(transport: transport,
algorithm: "m.rendezvous.v1.curve25519-aes-sha256",
key: "")
return QRLoginCode(user: client.credentials.userId,
initiator: .new,
rendezvous: rendezvous)
}
func scannerView() -> AnyView {
let frame = UIScreen.main.bounds
let view = UIView(frame: frame)
zxCapture.layer.frame = frame
view.layer.addSublayer(zxCapture.layer)
return AnyView(ViewWrapper(view: view))
}
func startScanning() {
Task { @MainActor in
if cameraAccessManager.isCameraAvailable {
let granted = await cameraAccessManager.requestCameraAccessIfNeeded()
if granted {
state = .scanningQR
zxCapture.delegate = self
zxCapture.camera = zxCapture.back()
zxCapture.start()
} else {
state = .failed(error: .noCameraAccess)
}
} else {
state = .failed(error: .noCameraAvailable)
}
}
}
func stopScanning(destroy: Bool) {
guard zxCapture.running else {
return
}
if destroy {
zxCapture.hard_stop()
} else {
zxCapture.stop()
}
}
func processScannedQR(_ data: Data) {
state = .connectingToDevice
do {
let code = try JSONDecoder().decode(QRLoginCode.self, from: data)
MXLog.debug("[QRLoginService] processScannedQR: \(code)")
// TODO: implement
} catch {
state = .failed(error: .invalidQR)
}
}
func confirmCode() {
switch state {
case .waitingForConfirmation(let code):
// TODO: implement
break
default:
return
}
}
func restart() {
state = .initial
}
func reset() {
stopScanning(destroy: false)
state = .initial
}
deinit {
stopScanning(destroy: true)
}
// MARK: Private
}
// MARK: - ZXCaptureDelegate
extension QRLoginService: ZXCaptureDelegate {
func captureCameraIsReady(_ capture: ZXCapture!) {
isCameraReady = true
}
func captureResult(_ capture: ZXCapture!, result: ZXResult!) {
guard isCameraReady,
let result = result,
result.barcodeFormat == kBarcodeFormatQRCode else {
return
}
stopScanning(destroy: false)
if let bytes = result.resultMetadata.object(forKey: kResultMetadataTypeByteSegments.rawValue) as? NSArray,
let byteArray = bytes.firstObject as? ZXByteArray {
let data = Data(bytes: UnsafeRawPointer(byteArray.array), count: Int(byteArray.length))
callbacks.send(.didScanQR(data))
}
}
}
// MARK: - ViewWrapper
private struct ViewWrapper: UIViewRepresentable {
var view: UIView
func makeUIView(context: Context) -> some UIView {
view
}
func updateUIView(_ uiView: UIViewType, context: Context) { }
}
@@ -0,0 +1,81 @@
//
// 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 Combine
import Foundation
import SwiftUI
class MockQRLoginService: QRLoginServiceProtocol {
init(withState state: QRLoginServiceState = .initial,
mode: QRLoginServiceMode = .notAuthenticated) {
self.state = state
self.mode = mode
}
// MARK: - QRLoginServiceProtocol
let mode: QRLoginServiceMode
var state: QRLoginServiceState {
didSet {
if state != oldValue {
callbacks.send(.didUpdateState)
}
}
}
let callbacks = PassthroughSubject<QRLoginServiceCallback, Never>()
func isServiceAvailable() async throws -> Bool {
true
}
func generateQRCode() async throws -> QRLoginCode {
let transport = QRLoginRendezvousTransportDetails(type: "http.v1",
uri: "https://matrix.org")
let rendezvous = QRLoginRendezvous(transport: transport,
algorithm: "m.rendezvous.v1.curve25519-aes-sha256",
key: "")
return QRLoginCode(user: "@mock:matrix.org",
initiator: .new,
rendezvous: rendezvous)
}
func scannerView() -> AnyView {
AnyView(Color.red)
}
func startScanning() { }
func stopScanning(destroy: Bool) { }
func processScannedQR(_ data: Data) {
state = .connectingToDevice
state = .waitingForConfirmation("28E-1B9-D0F-896")
}
func confirmCode() {
state = .waitingForRemoteSignIn
}
func restart() {
state = .initial
}
func reset() {
state = .initial
}
}
@@ -0,0 +1,97 @@
//
// 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 Combine
import Foundation
import SwiftUI
// MARK: - QRLoginServiceMode
enum QRLoginServiceMode {
case authenticated
case notAuthenticated
}
// MARK: - QRLoginServiceError
enum QRLoginServiceError: Error, Equatable {
case noCameraAccess
case noCameraAvailable
case invalidQR
case requestDenied
case requestTimedOut
}
// MARK: - QRLoginServiceState
enum QRLoginServiceState: Equatable {
case initial
case scanningQR
case connectingToDevice
case waitingForConfirmation(_ code: String)
case waitingForRemoteSignIn
case failed(error: QRLoginServiceError)
case completed
static func == (lhs: QRLoginServiceState, rhs: QRLoginServiceState) -> Bool {
switch (lhs, rhs) {
case (.initial, .initial):
return true
case (.scanningQR, .scanningQR):
return true
case (.connectingToDevice, .connectingToDevice):
return true
case (let .waitingForConfirmation(code1), let .waitingForConfirmation(code2)):
return code1 == code2
case (.waitingForRemoteSignIn, .waitingForRemoteSignIn):
return true
case (let .failed(error1), let .failed(error2)):
return error1 == error2
case (.completed, .completed):
return true
default:
return false
}
}
}
// MARK: - QRLoginServiceCallback
enum QRLoginServiceCallback {
case didScanQR(Data)
case didUpdateState
}
// MARK: - QRLoginServiceProtocol
protocol QRLoginServiceProtocol {
var mode: QRLoginServiceMode { get }
var state: QRLoginServiceState { get }
var callbacks: PassthroughSubject<QRLoginServiceCallback, Never> { get }
func isServiceAvailable() async throws -> Bool
func generateQRCode() async throws -> QRLoginCode
// MARK: QR Scanner
func scannerView() -> AnyView
func startScanning()
func stopScanning(destroy: Bool)
func processScannedQR(_ data: Data)
func confirmCode()
func restart()
func reset()
}
@@ -0,0 +1,63 @@
//
// 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 SwiftUI
struct LabelledDivider: View {
@Environment(\.theme) private var theme
let label: String
let font: Font? // theme.fonts.subheadline by default
let labelColor: Color? // theme.colors.primaryContent by default
let lineColor: Color? // theme.colors.quinaryContent by default
init(label: String,
font: Font? = nil,
labelColor: Color? = nil,
lineColor: Color? = nil) {
self.label = label
self.font = font
self.labelColor = labelColor
self.lineColor = lineColor
}
var body: some View {
HStack {
line
Text(label)
.foregroundColor(labelColor ?? theme.colors.primaryContent)
.font(font ?? theme.fonts.subheadline)
.fixedSize()
line
}
}
var line: some View {
VStack { Divider().background(lineColor ?? theme.colors.quinaryContent) }
}
}
// MARK: - Previews
struct LabelledDivider_Previews: PreviewProvider {
static var previews: some View {
LabelledDivider(label: "Label")
.theme(.light).preferredColorScheme(.light)
LabelledDivider(label: "Label")
.theme(.dark).preferredColorScheme(.dark)
}
}
@@ -0,0 +1,37 @@
//
// 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: - Coordinator
// MARK: View model
enum AuthenticationQRLoginConfirmViewModelResult {
case confirm
case cancel
}
// MARK: View
struct AuthenticationQRLoginConfirmViewState: BindableState {
var confirmationCode: String?
}
enum AuthenticationQRLoginConfirmViewAction {
case confirm
case cancel
}
@@ -0,0 +1,56 @@
//
// 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 AuthenticationQRLoginConfirmViewModelType = StateStoreViewModel<AuthenticationQRLoginConfirmViewState, AuthenticationQRLoginConfirmViewAction>
class AuthenticationQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelType, AuthenticationQRLoginConfirmViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let qrLoginService: QRLoginServiceProtocol
// MARK: Public
var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)?
// MARK: - Setup
init(qrLoginService: QRLoginServiceProtocol) {
self.qrLoginService = qrLoginService
super.init(initialViewState: AuthenticationQRLoginConfirmViewState())
switch qrLoginService.state {
case .waitingForConfirmation(let code):
state.confirmationCode = code
default:
break
}
}
// MARK: - Public
override func process(viewAction: AuthenticationQRLoginConfirmViewAction) {
switch viewAction {
case .confirm:
callback?(.confirm)
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,22 @@
//
// 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 AuthenticationQRLoginConfirmViewModelProtocol {
var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)? { get set }
var context: AuthenticationQRLoginConfirmViewModelType.Context { get }
}
@@ -0,0 +1,102 @@
//
// 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 CommonKit
import SwiftUI
struct AuthenticationQRLoginConfirmCoordinatorParameters {
let navigationRouter: NavigationRouterType
let qrLoginService: QRLoginServiceProtocol
}
enum AuthenticationQRLoginConfirmCoordinatorResult {
/// Login with QR done
case done
}
final class AuthenticationQRLoginConfirmCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationQRLoginConfirmCoordinatorParameters
private let onboardingQRLoginConfirmHostingController: VectorHostingController
private var onboardingQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationQRLoginConfirmCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: AuthenticationQRLoginConfirmCoordinatorParameters) {
self.parameters = parameters
let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: parameters.qrLoginService)
let view = AuthenticationQRLoginConfirmScreen(context: viewModel.context)
onboardingQRLoginConfirmViewModel = viewModel
onboardingQRLoginConfirmHostingController = VectorHostingController(rootView: view)
onboardingQRLoginConfirmHostingController.vc_removeBackTitle()
onboardingQRLoginConfirmHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginConfirmHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] did start.")
onboardingQRLoginConfirmViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] AuthenticationQRLoginConfirmViewModel did complete with result: \(result).")
switch result {
case .confirm:
self.parameters.qrLoginService.confirmCode()
case .cancel:
self.parameters.qrLoginService.reset()
}
}
}
func toPresentable() -> UIViewController {
onboardingQRLoginConfirmHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,50 @@
//
// 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
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationQRLoginConfirmScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case `default`
/// The associated screen
var screenType: Any.Type {
AuthenticationQRLoginConfirmScreen.self
}
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginConfirmScreenState] {
// Each of the presence statuses
[.default]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896")))
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel],
AnyView(AuthenticationQRLoginConfirmScreen(context: viewModel.context))
)
}
}
@@ -0,0 +1,37 @@
//
// 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 RiotSwiftUI
import XCTest
class AuthenticationQRLoginConfirmUITests: MockScreenTestCase {
func testDefault() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginConfirmScreenState.default.title)
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
XCTAssertTrue(app.staticTexts["confirmationCodeLabel"].exists)
XCTAssertTrue(app.staticTexts["alertText"].exists)
let confirmButton = app.buttons["confirmButton"]
XCTAssertTrue(confirmButton.exists)
XCTAssertTrue(confirmButton.isEnabled)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
}
}
@@ -0,0 +1,53 @@
//
// 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 RiotSwiftUI
class AuthenticationQRLoginConfirmViewModelTests: XCTestCase {
var viewModel: AuthenticationQRLoginConfirmViewModelProtocol!
var context: AuthenticationQRLoginConfirmViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896")))
context = viewModel.context
}
func testConfirm() {
var result: AuthenticationQRLoginConfirmViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .confirm)
XCTAssertEqual(result, .confirm)
}
func testCancel() {
var result: AuthenticationQRLoginConfirmViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .cancel)
XCTAssertEqual(result, .cancel)
}
}
@@ -0,0 +1,135 @@
//
// 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
/// The screen shown to a new user to select their use case for the app.
struct AuthenticationQRLoginConfirmScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
@ScaledMetric private var iconSize = 70.0
// MARK: Public
@ObservedObject var context: AuthenticationQRLoginConfirmViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
titleContent
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
codeView
}
.readableFrame()
footerContent
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
/// The screen's title and instructions.
var titleContent: some View {
VStack(spacing: 16) {
Image(Asset.Images.authenticationQrloginConfirmIcon.name)
.frame(width: iconSize, height: iconSize)
.padding(.bottom, 16)
Text(VectorL10n.authenticationQrLoginConfirmTitle)
.font(theme.fonts.title3SB)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("titleLabel")
Text(VectorL10n.authenticationQrLoginConfirmSubtitle)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 24)
.accessibilityIdentifier("subtitleLabel")
}
}
@ViewBuilder
var codeView: some View {
if let code = context.viewState.confirmationCode {
Text(code)
.multilineTextAlignment(.center)
.font(theme.fonts.title1)
.foregroundColor(theme.colors.primaryContent)
.padding(.top, 80)
.accessibilityIdentifier("confirmationCodeLabel")
}
}
/// The screen's footer.
var footerContent: some View {
VStack(spacing: 16) {
Text(VectorL10n.authenticationQrLoginConfirmAlert)
.padding(10)
.multilineTextAlignment(.center)
.font(theme.fonts.body)
.foregroundColor(theme.colors.alert)
.shapedBorder(color: theme.colors.alert, borderWidth: 1, shape: RoundedRectangle(cornerRadius: 8))
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 12)
.accessibilityIdentifier("alertText")
Button(action: confirm) {
Text(VectorL10n.confirm)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("confirmButton")
Button(action: cancel) {
Text(VectorL10n.cancel)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("cancelButton")
}
}
/// Sends the `confirm` view action.
func confirm() {
context.send(viewAction: .confirm)
}
/// Sends the `cancel` view action.
func cancel() {
context.send(viewAction: .cancel)
}
}
// MARK: - Previews
struct AuthenticationQRLoginConfirm_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationQRLoginConfirmScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
.navigationViewStyle(.stack)
}
}
@@ -0,0 +1,36 @@
//
// 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
import UIKit
// MARK: - Coordinator
// MARK: View model
enum AuthenticationQRLoginDisplayViewModelResult {
case cancel
}
// MARK: View
struct AuthenticationQRLoginDisplayViewState: BindableState {
var qrImage: UIImage?
}
enum AuthenticationQRLoginDisplayViewAction {
case cancel
}
@@ -0,0 +1,64 @@
//
// 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 AuthenticationQRLoginDisplayViewModelType = StateStoreViewModel<AuthenticationQRLoginDisplayViewState, AuthenticationQRLoginDisplayViewAction>
class AuthenticationQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelType, AuthenticationQRLoginDisplayViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let qrLoginService: QRLoginServiceProtocol
// MARK: Public
var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)?
// MARK: - Setup
init(qrLoginService: QRLoginServiceProtocol) {
self.qrLoginService = qrLoginService
super.init(initialViewState: AuthenticationQRLoginDisplayViewState())
Task { @MainActor in
let generator = QRCodeGenerator()
let qrData = try await qrLoginService.generateQRCode()
guard let jsonString = qrData.jsonString,
let data = jsonString.data(using: .isoLatin1) else {
return
}
do {
state.qrImage = try generator.generateCode(from: data,
with: CGSize(width: 240, height: 240),
offColor: .clear)
} catch {
// MXLog.error("[AuthenticationQRLoginDisplayViewModel] failed to generate QR", context: error)
}
}
}
// MARK: - Public
override func process(viewAction: AuthenticationQRLoginDisplayViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,22 @@
//
// 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 AuthenticationQRLoginDisplayViewModelProtocol {
var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)? { get set }
var context: AuthenticationQRLoginDisplayViewModelType.Context { get }
}
@@ -0,0 +1,103 @@
//
// 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 CommonKit
import SwiftUI
struct AuthenticationQRLoginDisplayCoordinatorParameters {
let navigationRouter: NavigationRouterType
let qrLoginService: QRLoginServiceProtocol
}
enum AuthenticationQRLoginDisplayCoordinatorResult {
/// Login with QR done
case done
}
final class AuthenticationQRLoginDisplayCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationQRLoginDisplayCoordinatorParameters
private let onboardingQRLoginDisplayHostingController: VectorHostingController
private var onboardingQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationQRLoginDisplayCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: AuthenticationQRLoginDisplayCoordinatorParameters) {
self.parameters = parameters
let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: parameters.qrLoginService)
let view = AuthenticationQRLoginDisplayScreen(context: viewModel.context)
onboardingQRLoginDisplayViewModel = viewModel
onboardingQRLoginDisplayHostingController = VectorHostingController(rootView: view)
onboardingQRLoginDisplayHostingController.vc_removeBackTitle()
onboardingQRLoginDisplayHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginDisplayHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] did start.")
onboardingQRLoginDisplayViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] AuthenticationQRLoginDisplayViewModel did complete with result: \(result).")
switch result {
case .cancel:
self.navigationRouter.popModule(animated: true)
}
}
}
func toPresentable() -> UIViewController {
onboardingQRLoginDisplayHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
private func showScanQRScreen() { }
private func showDisplayQRScreen() { }
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,50 @@
//
// 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
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationQRLoginDisplayScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case `default`
/// The associated screen
var screenType: Any.Type {
AuthenticationQRLoginDisplayScreen.self
}
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginDisplayScreenState] {
// Each of the presence statuses
[.default]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService())
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel],
AnyView(AuthenticationQRLoginDisplayScreen(context: viewModel.context))
)
}
}
@@ -0,0 +1,32 @@
//
// 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 RiotSwiftUI
import XCTest
class AuthenticationQRLoginDisplayUITests: MockScreenTestCase {
func testDefault() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginDisplayScreenState.default.title)
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
XCTAssertTrue(app.images["qrImageView"].exists)
let displayQRButton = app.buttons["cancelButton"]
XCTAssertTrue(displayQRButton.exists)
XCTAssertTrue(displayQRButton.isEnabled)
}
}
@@ -0,0 +1,41 @@
//
// 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 RiotSwiftUI
class AuthenticationQRLoginDisplayViewModelTests: XCTestCase {
var viewModel: AuthenticationQRLoginDisplayViewModelProtocol!
var context: AuthenticationQRLoginDisplayViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService())
context = viewModel.context
}
func testCancel() {
var result: AuthenticationQRLoginDisplayViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .cancel)
XCTAssertEqual(result, .cancel)
}
}
@@ -0,0 +1,148 @@
//
// 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
/// The screen shown to a new user to select their use case for the app.
struct AuthenticationQRLoginDisplayScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
// MARK: Public
@ObservedObject var context: AuthenticationQRLoginDisplayViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
titleContent
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
stepsView
qrView
}
.readableFrame()
footerContent
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
/// The screen's title and instructions.
var titleContent: some View {
VStack(spacing: 24) {
Text(VectorL10n.authenticationQrLoginDisplayTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("titleLabel")
Text(VectorL10n.authenticationQrLoginDisplaySubtitle)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 24)
.accessibilityIdentifier("subtitleLabel")
}
}
/// The screen's footer.
var footerContent: some View {
VStack(spacing: 8) {
Button(action: cancel) {
Text(VectorL10n.cancel)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("cancelButton")
}
}
/// The buttons used to select a use case for the app.
var stepsView: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(steps) { step in
HStack {
Text(String(step.id))
.font(theme.fonts.caption2SB)
.foregroundColor(theme.colors.accent)
.padding(6)
.shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle())
.offset(x: 1, y: 0)
Text(step.description)
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.subheadline)
Spacer()
}
}
}
}
@ViewBuilder
var qrView: some View {
if let qrImage = context.viewState.qrImage {
VStack {
Image(uiImage: qrImage)
.resizable()
.renderingMode(.template)
.foregroundColor(theme.colors.primaryContent)
.scaledToFit()
.accessibilityIdentifier("qrImageView")
}
.aspectRatio(1, contentMode: .fit)
.shapedBorder(color: theme.colors.quinaryContent,
borderWidth: 1,
shape: RoundedRectangle(cornerRadius: 8))
.padding(1)
.padding(.top, 16)
}
}
private let steps = [
QRLoginDisplayStep(id: 1, description: VectorL10n.authenticationQrLoginDisplayStep1),
QRLoginDisplayStep(id: 2, description: VectorL10n.authenticationQrLoginDisplayStep2)
]
/// Sends the `cancel` view action.
func cancel() {
context.send(viewAction: .cancel)
}
}
// MARK: - Previews
struct AuthenticationQRLoginDisplay_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationQRLoginDisplayScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
.navigationViewStyle(.stack)
}
}
private struct QRLoginDisplayStep: Identifiable {
let id: Int
let description: String
}
@@ -0,0 +1,38 @@
//
// 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: - Coordinator
// MARK: View model
enum AuthenticationQRLoginFailureViewModelResult {
case retry
case cancel
}
// MARK: View
struct AuthenticationQRLoginFailureViewState: BindableState {
var retryButtonVisible: Bool
var failureText: String?
}
enum AuthenticationQRLoginFailureViewAction {
case retry
case cancel
}
@@ -0,0 +1,82 @@
//
// 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 AuthenticationQRLoginFailureViewModelType = StateStoreViewModel<AuthenticationQRLoginFailureViewState, AuthenticationQRLoginFailureViewAction>
class AuthenticationQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelType, AuthenticationQRLoginFailureViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let qrLoginService: QRLoginServiceProtocol
// MARK: Public
var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)?
// MARK: - Setup
init(qrLoginService: QRLoginServiceProtocol) {
self.qrLoginService = qrLoginService
super.init(initialViewState: AuthenticationQRLoginFailureViewState(retryButtonVisible: false))
updateFailureText(for: qrLoginService.state)
qrLoginService.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .didUpdateState:
self.updateFailureText(for: qrLoginService.state)
default:
break
}
}
.store(in: &cancellables)
}
private func updateFailureText(for state: QRLoginServiceState) {
switch state {
case .failed(let error):
switch error {
case .invalidQR:
self.state.failureText = VectorL10n.authenticationQrLoginFailureInvalidQr
self.state.retryButtonVisible = true
case .requestDenied:
self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestDenied
self.state.retryButtonVisible = false
case .requestTimedOut:
self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestTimedOut
self.state.retryButtonVisible = true
default:
break
}
default:
break
}
}
// MARK: - Public
override func process(viewAction: AuthenticationQRLoginFailureViewAction) {
switch viewAction {
case .retry:
callback?(.retry)
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,22 @@
//
// 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 AuthenticationQRLoginFailureViewModelProtocol {
var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)? { get set }
var context: AuthenticationQRLoginFailureViewModelType.Context { get }
}
@@ -0,0 +1,103 @@
//
// 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 CommonKit
import SwiftUI
struct AuthenticationQRLoginFailureCoordinatorParameters {
let navigationRouter: NavigationRouterType
let qrLoginService: QRLoginServiceProtocol
}
enum AuthenticationQRLoginFailureCoordinatorResult {
/// Login with QR done
case done
}
final class AuthenticationQRLoginFailureCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationQRLoginFailureCoordinatorParameters
private let onboardingQRLoginFailureHostingController: VectorHostingController
private var onboardingQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationQRLoginFailureCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: AuthenticationQRLoginFailureCoordinatorParameters) {
self.parameters = parameters
let viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: parameters.qrLoginService)
let view = AuthenticationQRLoginFailureScreen(context: viewModel.context)
onboardingQRLoginFailureViewModel = viewModel
onboardingQRLoginFailureHostingController = VectorHostingController(rootView: view)
onboardingQRLoginFailureHostingController.vc_removeBackTitle()
onboardingQRLoginFailureHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginFailureHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationQRLoginFailureCoordinator] did start.")
onboardingQRLoginFailureViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationQRLoginFailureCoordinator] AuthenticationQRLoginFailureViewModel did complete with result: \(result).")
switch result {
case .retry:
self.qrLoginService.restart()
case .cancel:
self.qrLoginService.reset()
}
}
}
func toPresentable() -> UIViewController {
onboardingQRLoginFailureHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopFailure()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startFailure() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopFailure() {
loadingIndicator = nil
}
}
@@ -0,0 +1,61 @@
//
// 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
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case invalidQR
case requestDenied
case requestTimedOut
/// The associated screen
var screenType: Any.Type {
AuthenticationQRLoginFailureScreen.self
}
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginFailureScreenState] {
// Each of the presence statuses
[.invalidQR, .requestDenied, .requestTimedOut]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: AuthenticationQRLoginFailureViewModel
switch self {
case .invalidQR:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .invalidQR)))
case .requestDenied:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestDenied)))
case .requestTimedOut:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut)))
}
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel],
AnyView(AuthenticationQRLoginFailureScreen(context: viewModel.context))
)
}
}
@@ -0,0 +1,61 @@
//
// 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 RiotSwiftUI
import XCTest
class AuthenticationQRLoginFailureUITests: MockScreenTestCase {
func testInvalidQR() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.invalidQR.title)
XCTAssertTrue(app.staticTexts["failureLabel"].exists)
let retryButton = app.buttons["retryButton"]
XCTAssertTrue(retryButton.exists)
XCTAssertTrue(retryButton.isEnabled)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
}
func testRequestDenied() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestDenied.title)
XCTAssertTrue(app.staticTexts["failureLabel"].exists)
let retryButton = app.buttons["retryButton"]
XCTAssertFalse(retryButton.exists)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
}
func testRequestTimedOut() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestTimedOut.title)
XCTAssertTrue(app.staticTexts["failureLabel"].exists)
let retryButton = app.buttons["retryButton"]
XCTAssertTrue(retryButton.exists)
XCTAssertTrue(retryButton.isEnabled)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
}
}
@@ -0,0 +1,53 @@
//
// 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 RiotSwiftUI
class AuthenticationQRLoginFailureViewModelTests: XCTestCase {
var viewModel: AuthenticationQRLoginFailureViewModelProtocol!
var context: AuthenticationQRLoginFailureViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut)))
context = viewModel.context
}
func testRetry() {
var result: AuthenticationQRLoginFailureViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .retry)
XCTAssertEqual(result, .retry)
}
func testCancel() {
var result: AuthenticationQRLoginFailureViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .cancel)
XCTAssertEqual(result, .cancel)
}
}
@@ -0,0 +1,124 @@
//
// 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
/// The screen shown to a new user to select their use case for the app.
struct AuthenticationQRLoginFailureScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
@ScaledMetric private var iconSize = 70.0
// MARK: Public
@ObservedObject var context: AuthenticationQRLoginFailureViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
titleContent
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
}
.readableFrame()
footerContent
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
/// The screen's title and instructions.
var titleContent: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(theme.colors.alert)
Image(Asset.Images.exclamationCircle.name)
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.aspectRatio(1.0, contentMode: .fit)
.padding(15)
}
.frame(width: iconSize, height: iconSize)
.padding(.bottom, 16)
Text(VectorL10n.authenticationQrLoginFailureTitle)
.font(theme.fonts.title3SB)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("titleLabel")
if let failureText = context.viewState.failureText {
Text(failureText)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
.accessibilityIdentifier("failureLabel")
}
}
}
/// The screen's footer.
var footerContent: some View {
VStack(spacing: 16) {
if context.viewState.retryButtonVisible {
Button(action: retry) {
Text(VectorL10n.authenticationQrLoginFailureRetry)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("retryButton")
}
Button(action: cancel) {
Text(VectorL10n.cancel)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("cancelButton")
}
}
/// Sends the `retry` view action.
func retry() {
context.send(viewAction: .retry)
}
/// Sends the `cancel` view action.
func cancel() {
context.send(viewAction: .cancel)
}
}
// MARK: - Previews
struct AuthenticationQRLoginFailure_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationQRLoginFailureScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
.navigationViewStyle(.stack)
}
}
@@ -0,0 +1,35 @@
//
// 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: - Coordinator
// MARK: View model
enum AuthenticationQRLoginLoadingViewModelResult {
case cancel
}
// MARK: View
struct AuthenticationQRLoginLoadingViewState: BindableState {
var loadingText: String?
}
enum AuthenticationQRLoginLoadingViewAction {
case cancel
}
@@ -0,0 +1,72 @@
//
// 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 AuthenticationQRLoginLoadingViewModelType = StateStoreViewModel<AuthenticationQRLoginLoadingViewState, AuthenticationQRLoginLoadingViewAction>
class AuthenticationQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelType, AuthenticationQRLoginLoadingViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let qrLoginService: QRLoginServiceProtocol
// MARK: Public
var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)?
// MARK: - Setup
init(qrLoginService: QRLoginServiceProtocol) {
self.qrLoginService = qrLoginService
super.init(initialViewState: AuthenticationQRLoginLoadingViewState())
updateLoadingText(for: qrLoginService.state)
qrLoginService.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .didUpdateState:
self.updateLoadingText(for: qrLoginService.state)
default:
break
}
}
.store(in: &cancellables)
}
private func updateLoadingText(for state: QRLoginServiceState) {
switch state {
case .connectingToDevice:
self.state.loadingText = VectorL10n.authenticationQrLoginLoadingConnectingDevice
case .waitingForRemoteSignIn:
self.state.loadingText = VectorL10n.authenticationQrLoginLoadingWaitingSignin
case .completed:
self.state.loadingText = VectorL10n.authenticationQrLoginLoadingSignedIn
default:
break
}
}
// MARK: - Public
override func process(viewAction: AuthenticationQRLoginLoadingViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
}
}
}
@@ -0,0 +1,22 @@
//
// 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 AuthenticationQRLoginLoadingViewModelProtocol {
var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)? { get set }
var context: AuthenticationQRLoginLoadingViewModelType.Context { get }
}
@@ -0,0 +1,101 @@
//
// 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 CommonKit
import SwiftUI
struct AuthenticationQRLoginLoadingCoordinatorParameters {
let navigationRouter: NavigationRouterType
let qrLoginService: QRLoginServiceProtocol
}
enum AuthenticationQRLoginLoadingCoordinatorResult {
/// Login with QR done
case done
}
final class AuthenticationQRLoginLoadingCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationQRLoginLoadingCoordinatorParameters
private let onboardingQRLoginLoadingHostingController: VectorHostingController
private var onboardingQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationQRLoginLoadingCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: AuthenticationQRLoginLoadingCoordinatorParameters) {
self.parameters = parameters
let viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: parameters.qrLoginService)
let view = AuthenticationQRLoginLoadingScreen(context: viewModel.context)
onboardingQRLoginLoadingViewModel = viewModel
onboardingQRLoginLoadingHostingController = VectorHostingController(rootView: view)
onboardingQRLoginLoadingHostingController.vc_removeBackTitle()
onboardingQRLoginLoadingHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginLoadingHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] did start.")
onboardingQRLoginLoadingViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] AuthenticationQRLoginLoadingViewModel did complete with result: \(result).")
switch result {
case .cancel:
self.qrLoginService.reset()
}
}
}
func toPresentable() -> UIViewController {
onboardingQRLoginLoadingHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,61 @@
//
// 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
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationQRLoginLoadingScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case connectingToDevice
case waitingForRemoteSignIn
case completed
/// The associated screen
var screenType: Any.Type {
AuthenticationQRLoginLoadingScreen.self
}
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginLoadingScreenState] {
// Each of the presence statuses
[.connectingToDevice, .waitingForRemoteSignIn, .completed]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: AuthenticationQRLoginLoadingViewModel
switch self {
case .connectingToDevice:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .connectingToDevice))
case .waitingForRemoteSignIn:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .waitingForRemoteSignIn))
case .completed:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .completed))
}
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel],
AnyView(AuthenticationQRLoginLoadingScreen(context: viewModel.context))
)
}
}
@@ -0,0 +1,30 @@
//
// 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 RiotSwiftUI
import XCTest
class AuthenticationQRLoginLoadingUITests: MockScreenTestCase {
func testCommon() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginLoadingScreenState.connectingToDevice.title)
XCTAssertTrue(app.staticTexts["loadingLabel"].exists)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
}
}
@@ -0,0 +1,41 @@
//
// 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 RiotSwiftUI
class AuthenticationQRLoginLoadingViewModelTests: XCTestCase {
var viewModel: AuthenticationQRLoginLoadingViewModelProtocol!
var context: AuthenticationQRLoginLoadingViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: MockQRLoginService(withState: .connectingToDevice))
context = viewModel.context
}
func testCancel() {
var result: AuthenticationQRLoginLoadingViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .cancel)
XCTAssertEqual(result, .cancel)
}
}
@@ -0,0 +1,97 @@
//
// 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
/// The screen shown to a new user to select their use case for the app.
struct AuthenticationQRLoginLoadingScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
// MARK: Public
@ObservedObject var context: AuthenticationQRLoginLoadingViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
loadingText
.padding(.top, 60)
loader
}
.readableFrame()
footerContent
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
@ViewBuilder
var loadingText: some View {
if let code = context.viewState.loadingText {
Text(code)
.multilineTextAlignment(.center)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("loadingLabel")
}
}
@ViewBuilder
var loader: some View {
ProgressView()
.padding(.top, 64)
.accessibilityIdentifier("loader")
}
/// The screen's footer.
var footerContent: some View {
VStack(spacing: 8) {
Button(action: cancel) {
Text(VectorL10n.cancel)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("cancelButton")
}
}
/// Sends the `cancel` view action.
func cancel() {
context.send(viewAction: .cancel)
}
}
// MARK: - Previews
struct AuthenticationQRLoginLoading_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationQRLoginLoadingScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
.navigationViewStyle(.stack)
}
}
@@ -0,0 +1,53 @@
//
// 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
import SwiftUI
// MARK: - Coordinator
// MARK: View model
enum AuthenticationQRLoginScanViewModelResult: Equatable {
case goToSettings
case displayQR
case qrScanned(Data)
static func == (lhs: AuthenticationQRLoginScanViewModelResult, rhs: AuthenticationQRLoginScanViewModelResult) -> Bool {
switch (lhs, rhs) {
case (.goToSettings, .goToSettings):
return true
case (.displayQR, .displayQR):
return true
case (let .qrScanned(data1), let .qrScanned(data2)):
return data1 == data2
default:
return false
}
}
}
// MARK: View
struct AuthenticationQRLoginScanViewState: BindableState {
var serviceState: QRLoginServiceState
var scannerView: AnyView?
}
enum AuthenticationQRLoginScanViewAction {
case goToSettings
case displayQR
}
@@ -0,0 +1,75 @@
//
// 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 Combine
import SwiftUI
typealias AuthenticationQRLoginScanViewModelType = StateStoreViewModel<AuthenticationQRLoginScanViewState, AuthenticationQRLoginScanViewAction>
class AuthenticationQRLoginScanViewModel: AuthenticationQRLoginScanViewModelType, AuthenticationQRLoginScanViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let qrLoginService: QRLoginServiceProtocol
// MARK: Public
var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)?
// MARK: - Setup
init(qrLoginService: QRLoginServiceProtocol) {
self.qrLoginService = qrLoginService
super.init(initialViewState: AuthenticationQRLoginScanViewState(serviceState: .initial))
qrLoginService.callbacks.sink { callback in
switch callback {
case .didUpdateState:
self.processServiceState(qrLoginService.state)
case .didScanQR(let data):
self.callback?(.qrScanned(data))
}
}
.store(in: &cancellables)
processServiceState(qrLoginService.state)
qrLoginService.startScanning()
}
// MARK: - Public
override func process(viewAction: AuthenticationQRLoginScanViewAction) {
switch viewAction {
case .goToSettings:
callback?(.goToSettings)
case .displayQR:
callback?(.displayQR)
}
}
// MARK: - Private
private func processServiceState(_ state: QRLoginServiceState) {
switch state {
case .scanningQR:
self.state.scannerView = qrLoginService.scannerView()
default:
break
}
self.state.serviceState = state
}
}
@@ -0,0 +1,22 @@
//
// 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 AuthenticationQRLoginScanViewModelProtocol {
var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)? { get set }
var context: AuthenticationQRLoginScanViewModelType.Context { get }
}
@@ -0,0 +1,130 @@
//
// 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 CommonKit
import SwiftUI
struct AuthenticationQRLoginScanCoordinatorParameters {
let navigationRouter: NavigationRouterType
let qrLoginService: QRLoginServiceProtocol
}
enum AuthenticationQRLoginScanCoordinatorResult {
/// Login with QR done
case done
}
final class AuthenticationQRLoginScanCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationQRLoginScanCoordinatorParameters
private let onboardingQRLoginScanHostingController: VectorHostingController
private var onboardingQRLoginScanViewModel: AuthenticationQRLoginScanViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationQRLoginScanCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: AuthenticationQRLoginScanCoordinatorParameters) {
self.parameters = parameters
let viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: parameters.qrLoginService)
let view = AuthenticationQRLoginScanScreen(context: viewModel.context)
onboardingQRLoginScanViewModel = viewModel
onboardingQRLoginScanHostingController = VectorHostingController(rootView: view)
onboardingQRLoginScanHostingController.vc_removeBackTitle()
onboardingQRLoginScanHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginScanHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationQRLoginScanCoordinator] did start.")
onboardingQRLoginScanViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationQRLoginScanCoordinator] AuthenticationQRLoginScanViewModel did complete with result: \(result).")
switch result {
case .goToSettings:
self.goToSettings()
case .displayQR:
self.showDisplayQRScreen()
case .qrScanned(let data):
self.qrLoginService.stopScanning(destroy: false)
self.qrLoginService.processScannedQR(data)
}
}
}
func toPresentable() -> UIViewController {
onboardingQRLoginScanHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
private func goToSettings() {
UIApplication.shared.vc_openSettings()
}
/// Shows the display QR screen.
private func showDisplayQRScreen() {
MXLog.debug("[AuthenticationQRLoginScanCoordinator] showDisplayQRScreen")
let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: qrLoginService)
let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,61 @@
//
// 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
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationQRLoginScanScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case scanning
case noCameraAvailable
case noCameraAccess
/// The associated screen
var screenType: Any.Type {
AuthenticationQRLoginScanScreen.self
}
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginScanScreenState] {
// Each of the presence statuses
[.scanning, .noCameraAvailable, .noCameraAccess]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: AuthenticationQRLoginScanViewModel
switch self {
case .scanning:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .scanningQR))
case .noCameraAvailable:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .noCameraAvailable)))
case .noCameraAccess:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .noCameraAccess)))
}
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel],
AnyView(AuthenticationQRLoginScanScreen(context: viewModel.context))
)
}
}
@@ -0,0 +1,53 @@
//
// 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 RiotSwiftUI
import XCTest
class AuthenticationQRLoginScanUITests: MockScreenTestCase {
func testScanning() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.scanning.title)
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
}
func testNoCameraAvailable() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAvailable.title)
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
let displayQRButton = app.buttons["displayQRButton"]
XCTAssertTrue(displayQRButton.exists)
XCTAssertTrue(displayQRButton.isEnabled)
}
func testNoCameraAccess() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAccess.title)
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
let openSettingsButton = app.buttons["openSettingsButton"]
XCTAssertTrue(openSettingsButton.exists)
XCTAssertTrue(openSettingsButton.isEnabled)
let displayQRButton = app.buttons["displayQRButton"]
XCTAssertTrue(displayQRButton.exists)
XCTAssertTrue(displayQRButton.isEnabled)
}
}
@@ -0,0 +1,53 @@
//
// 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 RiotSwiftUI
class AuthenticationQRLoginScanViewModelTests: XCTestCase {
var viewModel: AuthenticationQRLoginScanViewModelProtocol!
var context: AuthenticationQRLoginScanViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: MockQRLoginService())
context = viewModel.context
}
func testGoToSettings() {
var result: AuthenticationQRLoginScanViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .goToSettings)
XCTAssertEqual(result, .goToSettings)
}
func testDisplayQR() {
var result: AuthenticationQRLoginScanViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .displayQR)
XCTAssertEqual(result, .displayQR)
}
}
@@ -0,0 +1,212 @@
//
// 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
/// The screen shown to a new user to select their use case for the app.
struct AuthenticationQRLoginScanScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
@ScaledMetric private var iconSize = 70.0
private let overlayBgColor = Color.black.opacity(0.4)
// MARK: Public
@ObservedObject var context: AuthenticationQRLoginScanViewModel.Context
var body: some View {
switch context.viewState.serviceState {
case .scanningQR:
scanningBody
case .failed(let error):
switch error {
case .noCameraAvailable, .noCameraAccess:
errorBody(for: error)
default:
EmptyView()
}
default:
EmptyView()
}
}
var scanningBody: some View {
ZStack {
if let scannerView = context.viewState.scannerView {
scannerView
.frame(maxWidth: .infinity)
.background(Color.black)
}
overlayView
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
var overlayView: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
VStack {
Spacer()
scanningTitleContent
.padding(.horizontal, 40)
Spacer()
.frame(height: 16)
}
.frame(height: additionalViewHeight(in: geometry))
.frame(maxWidth: .infinity)
.background(overlayBgColor)
HStack(spacing: 0) {
overlayBgColor
.frame(width: 40)
Spacer()
overlayBgColor
.frame(width: 40)
}
.frame(maxWidth: .infinity)
overlayBgColor
.frame(height: additionalViewHeight(in: geometry))
}
}
.ignoresSafeArea()
}
/// The screen's title and instructions.
var scanningTitleContent: some View {
VStack(spacing: 24) {
Text(VectorL10n.authenticationQrLoginScanTitle)
.font(theme.fonts.title1B)
.multilineTextAlignment(.center)
.foregroundColor(.white)
.accessibilityIdentifier("titleLabel")
Text(VectorL10n.authenticationQrLoginScanSubtitle)
.font(theme.fonts.bodySB)
.multilineTextAlignment(.center)
.foregroundColor(.white)
.padding(.bottom, 24)
.accessibilityIdentifier("subtitleLabel")
}
}
func errorBody(for error: QRLoginServiceError) -> some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
errorTitleContent(for: error)
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
}
.readableFrame()
errorFooterContent(for: error)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
/// The screen's title and instructions on error.
func errorTitleContent(for error: QRLoginServiceError) -> some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(theme.colors.accent)
Image(Asset.Images.camera.name)
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.aspectRatio(1.0, contentMode: .fit)
.padding(14)
}
.frame(width: iconSize, height: iconSize)
.padding(.bottom, 16)
Text(VectorL10n.authenticationQrLoginStartTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("titleLabel")
Text(error == .noCameraAccess ? VectorL10n.cameraAccessNotGranted(AppInfo.current.displayName) : VectorL10n.cameraUnavailable)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 24)
.accessibilityIdentifier("subtitleLabel")
}
}
/// The screen's footer on error.
func errorFooterContent(for error: QRLoginServiceError) -> some View {
VStack(spacing: 12) {
if error == .noCameraAccess {
Button(action: goToSettings) {
Text(VectorL10n.settings)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.padding(.bottom, 8)
.accessibilityIdentifier("openSettingsButton")
}
LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative)
Button(action: displayQR) {
Text(VectorL10n.authenticationQrLoginStartDisplayQr)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("displayQRButton")
}
}
/// Sends the `goToSettings` view action.
func goToSettings() {
context.send(viewAction: .goToSettings)
}
/// Sends the `displayQR` view action.
func displayQR() {
context.send(viewAction: .displayQR)
}
func squareSize(in geometry: GeometryProxy) -> CGFloat {
geometry.size.width - 80
}
func additionalViewHeight(in geometry: GeometryProxy) -> CGFloat {
(geometry.size.height - squareSize(in: geometry)) / 2
}
}
// MARK: - Previews
struct AuthenticationQRLoginScan_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationQRLoginScanScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
.navigationViewStyle(.stack)
}
}
@@ -0,0 +1,35 @@
//
// 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: - Coordinator
// MARK: View model
enum AuthenticationQRLoginStartViewModelResult {
case scanQR
case displayQR
}
// MARK: View
struct AuthenticationQRLoginStartViewState: BindableState { }
enum AuthenticationQRLoginStartViewAction {
case scanQR
case displayQR
}
@@ -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
typealias AuthenticationQRLoginStartViewModelType = StateStoreViewModel<AuthenticationQRLoginStartViewState, AuthenticationQRLoginStartViewAction>
class AuthenticationQRLoginStartViewModel: AuthenticationQRLoginStartViewModelType, AuthenticationQRLoginStartViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let qrLoginService: QRLoginServiceProtocol
// MARK: Public
var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)?
// MARK: - Setup
init(qrLoginService: QRLoginServiceProtocol) {
self.qrLoginService = qrLoginService
super.init(initialViewState: AuthenticationQRLoginStartViewState())
}
// MARK: - Public
override func process(viewAction: AuthenticationQRLoginStartViewAction) {
switch viewAction {
case .scanQR:
callback?(.scanQR)
case .displayQR:
callback?(.displayQR)
}
}
}
@@ -0,0 +1,22 @@
//
// 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 AuthenticationQRLoginStartViewModelProtocol {
var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)? { get set }
var context: AuthenticationQRLoginStartViewModelType.Context { get }
}
@@ -0,0 +1,269 @@
//
// 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 Combine
import CommonKit
import SwiftUI
struct AuthenticationQRLoginStartCoordinatorParameters {
let navigationRouter: NavigationRouterType
let qrLoginService: QRLoginServiceProtocol
}
enum AuthenticationQRLoginStartCoordinatorResult {
/// Login with QR done
case done
}
final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: AuthenticationQRLoginStartCoordinatorParameters
private let onboardingQRLoginStartHostingController: VectorHostingController
private var onboardingQRLoginStartViewModel: AuthenticationQRLoginStartViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var cancellables = Set<AnyCancellable>()
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((AuthenticationQRLoginStartCoordinatorResult) -> Void)?
// MARK: - Setup
init(parameters: AuthenticationQRLoginStartCoordinatorParameters) {
self.parameters = parameters
let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: parameters.qrLoginService)
let view = AuthenticationQRLoginStartScreen(context: viewModel.context)
onboardingQRLoginStartViewModel = viewModel
onboardingQRLoginStartHostingController = VectorHostingController(rootView: view)
onboardingQRLoginStartHostingController.vc_removeBackTitle()
onboardingQRLoginStartHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginStartHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] did start.")
onboardingQRLoginStartViewModel.callback = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AuthenticationQRLoginStartCoordinator] AuthenticationQRLoginStartViewModel did complete with result: \(result).")
switch result {
case .scanQR:
self.showScanQRScreen()
case .displayQR:
self.showDisplayQRScreen()
}
}
qrLoginService.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .didUpdateState:
self.processServiceState(self.qrLoginService.state)
default:
break
}
}
.store(in: &cancellables)
}
func toPresentable() -> UIViewController {
onboardingQRLoginStartHostingController
}
/// Stops any ongoing activities in the coordinator.
func stop() {
stopLoading()
}
// MARK: - Private
private func processServiceState(_ state: QRLoginServiceState) {
switch state {
case .initial:
removeAllChildren()
case .connectingToDevice, .waitingForRemoteSignIn, .completed:
showLoadingScreenIfNeeded()
case .waitingForConfirmation:
showConfirmationScreenIfNeeded()
case .failed(let error):
switch error {
case .noCameraAccess, .noCameraAvailable:
// handled in scanning screen
break
default:
showFailureScreenIfNeeded()
}
default:
break
}
}
private func removeAllChildren(animated: Bool = true) {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] removeAllChildren")
guard !childCoordinators.isEmpty else {
return
}
for coordinator in childCoordinators.reversed() {
remove(childCoordinator: coordinator)
}
navigationRouter.popToModule(self, animated: animated)
}
/// Shows the scan QR screen.
private func showScanQRScreen() {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showScanQRScreen")
let parameters = AuthenticationQRLoginScanCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: qrLoginService)
let coordinator = AuthenticationQRLoginScanCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Shows the display QR screen.
private func showDisplayQRScreen() {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showDisplayQRScreen")
let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: qrLoginService)
let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Shows the loading screen.
private func showLoadingScreenIfNeeded() {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showLoadingScreenIfNeeded")
if let lastCoordinator = childCoordinators.last,
lastCoordinator is AuthenticationQRLoginLoadingCoordinator {
// if the last screen is loading, do nothing. It'll be updated by the service state.
return
}
let parameters = AuthenticationQRLoginLoadingCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: qrLoginService)
let coordinator = AuthenticationQRLoginLoadingCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Shows the confirmation screen.
private func showConfirmationScreenIfNeeded() {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showConfirmationScreenIfNeeded")
if let lastCoordinator = childCoordinators.last,
lastCoordinator is AuthenticationQRLoginConfirmCoordinator {
// if the last screen is confirmation, do nothing. It'll be updated by the service state.
return
}
let parameters = AuthenticationQRLoginConfirmCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: qrLoginService)
let coordinator = AuthenticationQRLoginConfirmCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Shows the failure screen.
private func showFailureScreenIfNeeded() {
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showFailureScreenIfNeeded")
if let lastCoordinator = childCoordinators.last,
lastCoordinator is AuthenticationQRLoginFailureCoordinator {
// if the last screen is failure, do nothing. It'll be updated by the service state.
return
}
let parameters = AuthenticationQRLoginFailureCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: qrLoginService)
let coordinator = AuthenticationQRLoginFailureCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
coordinator.start()
add(childCoordinator: coordinator)
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Show an activity indicator whilst loading.
private func startLoading() {
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
@@ -0,0 +1,50 @@
//
// 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
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockAuthenticationQRLoginStartScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case `default`
/// The associated screen
var screenType: Any.Type {
AuthenticationQRLoginStartScreen.self
}
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginStartScreenState] {
// Each of the presence statuses
[.default]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService())
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel],
AnyView(AuthenticationQRLoginStartScreen(context: viewModel.context))
)
}
}
@@ -0,0 +1,35 @@
//
// 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 RiotSwiftUI
import XCTest
class AuthenticationQRLoginStartUITests: MockScreenTestCase {
func testDefault() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginStartScreenState.default.title)
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
let scanQRButton = app.buttons["scanQRButton"]
XCTAssertTrue(scanQRButton.exists)
XCTAssertTrue(scanQRButton.isEnabled)
let displayQRButton = app.buttons["displayQRButton"]
XCTAssertTrue(displayQRButton.exists)
XCTAssertTrue(displayQRButton.isEnabled)
}
}
@@ -0,0 +1,53 @@
//
// 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 RiotSwiftUI
class AuthenticationQRLoginStartViewModelTests: XCTestCase {
var viewModel: AuthenticationQRLoginStartViewModelProtocol!
var context: AuthenticationQRLoginStartViewModelType.Context!
override func setUpWithError() throws {
viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService())
context = viewModel.context
}
func testScanQR() {
var result: AuthenticationQRLoginStartViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .scanQR)
XCTAssertEqual(result, .scanQR)
}
func testDisplayQR() {
var result: AuthenticationQRLoginStartViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
context.send(viewAction: .displayQR)
XCTAssertEqual(result, .displayQR)
}
}
@@ -0,0 +1,157 @@
//
// 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
/// The screen shown to a new user to select their use case for the app.
struct AuthenticationQRLoginStartScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
@ScaledMetric private var iconSize = 70.0
// MARK: Public
@ObservedObject var context: AuthenticationQRLoginStartViewModel.Context
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
titleContent
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
stepsView
}
.readableFrame()
footerContent
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
.padding(.horizontal, 16)
}
.background(theme.colors.background.ignoresSafeArea())
}
/// The screen's title and instructions.
var titleContent: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(theme.colors.accent)
Image(Asset.Images.camera.name)
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.aspectRatio(1.0, contentMode: .fit)
.padding(14)
}
.frame(width: iconSize, height: iconSize)
.padding(.bottom, 16)
Text(VectorL10n.authenticationQrLoginStartTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("titleLabel")
Text(VectorL10n.authenticationQrLoginStartSubtitle)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 24)
.accessibilityIdentifier("subtitleLabel")
}
}
/// The screen's footer.
var footerContent: some View {
VStack(spacing: 12) {
Button(action: scanQR) {
Text(VectorL10n.authenticationQrLoginStartTitle)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.padding(.bottom, 8)
.accessibilityIdentifier("scanQRButton")
LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative)
Button(action: displayQR) {
Text(VectorL10n.authenticationQrLoginStartDisplayQr)
}
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
.accessibilityIdentifier("displayQRButton")
}
}
/// The buttons used to select a use case for the app.
var stepsView: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(steps) { step in
HStack {
Text(String(step.id))
.font(theme.fonts.caption2SB)
.foregroundColor(theme.colors.accent)
.padding(6)
.shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle())
.offset(x: 1, y: 0)
Text(step.description)
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.subheadline)
Spacer()
}
}
}
}
private let steps = [
QRLoginStartStep(id: 1, description: VectorL10n.authenticationQrLoginStartStep1),
QRLoginStartStep(id: 2, description: VectorL10n.authenticationQrLoginStartStep2),
QRLoginStartStep(id: 3, description: VectorL10n.authenticationQrLoginStartStep3),
QRLoginStartStep(id: 4, description: VectorL10n.authenticationQrLoginStartStep4)
]
/// Sends the `scanQR` view action.
func scanQR() {
context.send(viewAction: .scanQR)
}
/// Sends the `displayQR` view action.
func displayQR() {
context.send(viewAction: .displayQR)
}
}
// MARK: - Previews
struct AuthenticationQRLoginStart_Previews: PreviewProvider {
static let stateRenderer = MockAuthenticationQRLoginStartScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
.navigationViewStyle(.stack)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
.navigationViewStyle(.stack)
}
}
private struct QRLoginStartStep: Identifiable {
let id: Int
let description: String
}
@@ -35,6 +35,12 @@ enum MockAppScreens {
MockAuthenticationForgotPasswordScreenState.self,
MockAuthenticationChoosePasswordScreenState.self,
MockAuthenticationSoftLogoutScreenState.self,
MockAuthenticationQRLoginStartScreenState.self,
MockAuthenticationQRLoginDisplayScreenState.self,
MockAuthenticationQRLoginScanScreenState.self,
MockAuthenticationQRLoginConfirmScreenState.self,
MockAuthenticationQRLoginLoadingScreenState.self,
MockAuthenticationQRLoginFailureScreenState.self,
MockOnboardingCelebrationScreenState.self,
MockOnboardingAvatarScreenState.self,
MockOnboardingDisplayNameScreenState.self,
@@ -19,8 +19,11 @@ import SwiftUI
struct PrimaryActionButtonStyle: ButtonStyle {
@Environment(\.theme) private var theme
@Environment(\.isEnabled) private var isEnabled
/// `theme.colors.accent` by default
var customColor: Color?
/// `theme.colors.body` by default
var font: Font?
private var fontColor: Color {
// Always white unless disabled with a dark theme.
@@ -36,7 +39,7 @@ struct PrimaryActionButtonStyle: ButtonStyle {
.padding(12.0)
.frame(maxWidth: .infinity)
.foregroundColor(fontColor)
.font(theme.fonts.body)
.font(font ?? theme.fonts.body)
.background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed)))
.cornerRadius(8.0)
}
@@ -19,15 +19,18 @@ import SwiftUI
struct SecondaryActionButtonStyle: ButtonStyle {
@Environment(\.theme) private var theme
@Environment(\.isEnabled) private var isEnabled
/// `theme.colors.accent` by default
var customColor: Color?
/// `theme.fonts.body` by default
var font: Font?
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(12.0)
.frame(maxWidth: .infinity)
.foregroundColor(customColor ?? theme.colors.accent)
.font(theme.fonts.body)
.font(font ?? theme.fonts.body)
.background(RoundedRectangle(cornerRadius: 8)
.strokeBorder()
.foregroundColor(customColor ?? theme.colors.accent))
@@ -1,4 +1,4 @@
//
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -139,21 +139,21 @@ struct UserSessionCardViewPreview: View {
init(isCurrent: Bool = false) {
let sessionInfo = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
clientName: "Element",
clientVersion: "1.0.0",
isActive: true,
isCurrent: isCurrent)
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
clientName: "Element",
clientVersion: "1.0.0",
isActive: true,
isCurrent: isCurrent)
viewData = UserSessionCardViewData(sessionInfo: sessionInfo)
}
@@ -41,7 +41,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsFlowCoordinatorParameters) {
self.parameters = parameters
self.navigationRouter = parameters.router
navigationRouter = parameters.router
errorPresenter = MXKErrorAlertPresentation()
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable())
}
@@ -71,10 +71,12 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
self.showLogoutConfirmation(for: sessionInfo)
case let .openSessionOverview(sessionInfo: sessionInfo):
self.openSessionOverview(sessionInfo: sessionInfo)
case let .openOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
self.openOtherSessions(sessionsInfo: sessionsInfo,
case let .openOtherSessions(sessionInfos: sessionInfos, filter: filter):
self.openOtherSessions(sessionInfos: sessionInfos,
filterBy: filter,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
case .linkDevice:
self.openQRLoginScreen()
}
}
return coordinator
@@ -105,6 +107,21 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
}
pushScreen(with: coordinator)
}
/// Shows the QR login screen.
private func openQRLoginScreen() {
let service = QRLoginService(client: parameters.session.matrixRestClient,
mode: .authenticated)
let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter,
qrLoginService: service)
let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] _ in
guard let self = self, let coordinator = coordinator else { return }
self.remove(childCoordinator: coordinator)
}
pushScreen(with: coordinator)
}
private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator {
let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session,
@@ -112,8 +129,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
return UserSessionOverviewCoordinator(parameters: parameters)
}
private func openOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
let coordinator = createOtherSessionsCoordinator(sessionsInfo: sessionsInfo,
private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos,
filterBy: filter,
title: title)
coordinator.completion = { [weak self] result in
@@ -126,16 +143,15 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
pushScreen(with: coordinator)
}
private func createOtherSessionsCoordinator(sessionsInfo: [UserSessionInfo],
private func createOtherSessionsCoordinator(sessionInfos: [UserSessionInfo],
filterBy filter: OtherUserSessionsFilter,
title: String) -> UserOtherSessionsCoordinator {
let parameters = UserOtherSessionsCoordinatorParameters(sessionsInfo: sessionsInfo,
let parameters = UserOtherSessionsCoordinatorParameters(sessionInfos: sessionInfos,
filter: filter,
title: title)
return UserOtherSessionsCoordinator(parameters: parameters)
}
/// Shows a confirmation dialog to the user to sign out of a session.
private func showLogoutConfirmation(for sessionInfo: UserSessionInfo) {
// Use a UIAlertController as we don't have confirmationDialog in SwiftUI on iOS 14.
@@ -18,13 +18,12 @@ import CommonKit
import SwiftUI
struct UserOtherSessionsCoordinatorParameters {
let sessionsInfo: [UserSessionInfo]
let sessionInfos: [UserSessionInfo]
let filter: OtherUserSessionsFilter
let title: String
}
final class UserOtherSessionsCoordinator: Coordinator, Presentable {
private let parameters: UserOtherSessionsCoordinatorParameters
private let userOtherSessionsHostingController: UIViewController
private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol
@@ -38,7 +37,7 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable {
init(parameters: UserOtherSessionsCoordinatorParameters) {
self.parameters = parameters
let viewModel = UserOtherSessionsViewModel(sessionsInfo: parameters.sessionsInfo,
let viewModel = UserOtherSessionsViewModel(sessionInfos: parameters.sessionInfos,
filter: parameters.filter,
title: parameters.title)
let view = UserOtherSessions(viewModel: viewModel.context)
@@ -25,6 +25,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
// mock that screen.
case inactiveSessions
case unverifiedSessions
/// The associated screen
var screenType: Any.Type {
@@ -34,15 +35,22 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
/// A list of screen state definitions
static var allCases: [MockUserOtherSessionsScreenState] {
// Each of the presence statuses
[.inactiveSessions]
[.inactiveSessions, .unverifiedSessions]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel = UserOtherSessionsViewModel(sessionsInfo: inactiveSessions(),
let viewModel: UserOtherSessionsViewModel
switch self {
case .inactiveSessions:
viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(),
filter: .inactive,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
case .unverifiedSessions:
viewModel = UserOtherSessionsViewModel(sessionInfos: unverifiedSessions(),
filter: .unverified,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
}
// can simulate service and viewModel actions here if needs be.
@@ -74,7 +82,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
deviceType: .desktop,
isVerified: true,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
@@ -90,7 +98,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
deviceType: .web,
isVerified: true,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000,
lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
@@ -106,7 +114,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
deviceType: .mobile,
isVerified: false,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000,
lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
@@ -118,4 +126,39 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isActive: false,
isCurrent: false)]
}
private func unverifiedSessions() -> [UserSessionInfo] {
[UserSessionInfo(id: "0",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: true,
isCurrent: true),
UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: false,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
clientName: nil,
clientVersion: nil,
isActive: true,
isCurrent: false)]
}
}
@@ -18,12 +18,11 @@ import RiotSwiftUI
import XCTest
class UserOtherSessionsUITests: MockScreenTestCase {
func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() {
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists)
}
func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() {
@@ -31,4 +30,17 @@ class UserOtherSessionsUITests: MockScreenTestCase {
XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Inactive for 90+ days"].exists)
}
func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctHeaderDisplayed() {
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists)
}
func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctItemsDisplayed() {
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title)
XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Unverified · Your current session"].exists)
}
}
@@ -19,14 +19,12 @@ import XCTest
@testable import RiotSwiftUI
class UserOtherSessionsViewModelTests: XCTestCase {
func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() {
let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2")
let sut = UserOtherSessionsViewModel(sessionsInfo: [createUserSessionInfo(sessionId: "session 1"),
expectedUserSessionInfo],
filter: .inactive,
title: "Title")
let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"),
expectedUserSessionInfo],
filter: .inactive,
title: "Title")
var modelResult: UserOtherSessionsViewModelResult?
sut.completion = { result in
@@ -37,21 +35,20 @@ class UserOtherSessionsViewModelTests: XCTestCase {
}
func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() {
let sessionsInfo = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
let sut = UserOtherSessionsViewModel(sessionsInfo: sessionsInfo,
filter: .inactive,
title: "Title")
let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos,
filter: .inactive,
title: "Title")
let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
iconName: Asset.Images.userOtherSessionsInactive.name)
let expectedItems = sessionsInfo.filter { !$0.isActive }.asViewData()
let expectedItems = sessionInfos.filter { !$0.isActive }.asViewData()
let expectedState = UserOtherSessionsViewState(title: "Title",
sections: [.sessionItems(header: expectedHeader, items: expectedItems)])
XCTAssertEqual(sut.state, expectedState)
}
private func createUserSessionInfo(sessionId: String) -> UserSessionInfo {
UserSessionInfo(id: sessionId,
name: "iOS",
@@ -17,6 +17,7 @@
import Foundation
// MARK: - Coordinator
enum UserOtherSessionsCoordinatorResult {
case openSessionDetails(sessionInfo: UserSessionInfo)
}
@@ -38,6 +39,7 @@ enum UserOtherSessionsSection: Hashable, Identifiable {
var id: Self {
self
}
case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData])
}
@@ -25,16 +25,15 @@ enum OtherUserSessionsFilter {
}
class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol {
var completion: ((UserOtherSessionsViewModelResult) -> Void)?
private let sessionsInfo: [UserSessionInfo]
private let sessionInfos: [UserSessionInfo]
init(sessionsInfo: [UserSessionInfo],
init(sessionInfos: [UserSessionInfo],
filter: OtherUserSessionsFilter,
title: String) {
self.sessionsInfo = sessionsInfo
self.sessionInfos = sessionInfos
super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: []))
updateViewState(sessionsInfo: sessionsInfo, filter: filter)
updateViewState(sessionInfos: sessionInfos, filter: filter)
}
// MARK: - Public
@@ -42,7 +41,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
override func process(viewAction: UserOtherSessionsViewAction) {
switch viewAction {
case let .userOtherSessionSelected(sessionId: sessionId):
guard let session = sessionsInfo.first(where: {$0.id == sessionId}) else {
guard let session = sessionInfos.first(where: { $0.id == sessionId }) else {
assertionFailure("Session should exist in the array.")
return
}
@@ -52,20 +51,28 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
// MARK: - Private
private func updateViewState(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) {
let sectionItems = filterSessions(sessionsInfo: sessionsInfo, by: filter).asViewData()
private func updateViewState(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) {
let sectionItems = createSectionItems(sessionInfos: sessionInfos, filter: filter)
let sectionHeader = createHeaderData(filter: filter)
state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)]
}
private func filterSessions(sessionsInfo: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
private func createSectionItems(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) -> [UserSessionListItemViewData] {
filterSessions(sessionInfos: sessionInfos, by: filter)
.map {
UserSessionListItemViewDataFactory().create(from: $0,
highlightSessionDetails: filter == .unverified && $0.isCurrent)
}
}
private func filterSessions(sessionInfos: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
switch filter {
case .all:
return sessionsInfo.filter { !$0.isCurrent }
return sessionInfos.filter { !$0.isCurrent }
case .inactive:
return sessionsInfo.filter { !$0.isActive }
return sessionInfos.filter { !$0.isActive }
case .unverified:
return sessionsInfo.filter { !$0.isVerified }
return sessionInfos.filter { !$0.isVerified }
}
}
@@ -81,11 +88,9 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
iconName: Asset.Images.userOtherSessionsInactive.name)
case .unverified:
// TODO:
return UserOtherSessionsHeaderViewData(title: nil,
subtitle: "",
iconName: nil)
return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle,
subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle,
iconName: Asset.Images.userOtherSessionsUnverified.name)
}
}
}
@@ -17,7 +17,6 @@
import SwiftUI
struct UserOtherSessions: View {
@Environment(\.theme) private var theme
@ObservedObject var viewModel: UserOtherSessionsViewModel.Context
@@ -57,7 +56,6 @@ struct UserOtherSessions: View {
// MARK: - Previews
struct UserOtherSessions_Previews: PreviewProvider {
static let stateRenderer = MockUserOtherSessionsScreenState.stateRenderer
static var previews: some View {
@@ -18,12 +18,11 @@ import SwiftUI
struct UserOtherSessionsHeaderViewData: Hashable {
var title: String?
var subtitle: String
let subtitle: String
var iconName: String?
}
struct UserOtherSessionsHeaderView: View {
private var backgroundShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 8)
}
@@ -33,10 +32,9 @@ struct UserOtherSessionsHeaderView: View {
let viewData: UserOtherSessionsHeaderViewData
var body: some View {
HStack (alignment: .top, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
if let iconName = viewData.iconName {
Image(iconName)
.foregroundColor(.red)
.frame(width: 40, height: 40)
.background(theme.colors.background)
.clipShape(backgroundShape)
@@ -64,12 +62,10 @@ struct UserOtherSessionsHeaderView: View {
// MARK: - Previews
struct UserOtherSessionsHeaderView_Previews: PreviewProvider {
private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
iconName: Asset.Images.userOtherSessionsInactive.name)
static var previews: some View {
Group {
UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData)
@@ -42,38 +42,38 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
switch self {
case .allSections:
sessionInfo = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
clientName: "Element",
clientVersion: "1.0.0",
isActive: true,
isCurrent: true)
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
clientName: "Element",
clientVersion: "1.0.0",
isActive: true,
isCurrent: true)
case .sessionSectionOnly:
sessionInfo = UserSessionInfo(id: "3",
name: "Android",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Android 4.0",
lastSeenIPLocation: nil,
clientName: "Element",
clientVersion: "1.0.0",
isActive: true,
isCurrent: false)
name: "Android",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Android 4.0",
lastSeenIPLocation: nil,
clientName: "Element",
clientVersion: "1.0.0",
isActive: true,
isCurrent: false)
}
let viewModel = UserSessionDetailsViewModel(sessionInfo: sessionInfo)
@@ -18,7 +18,6 @@ import Combine
import MatrixSDK
class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
// MARK: - Members
private(set) var pusherEnabledSubject: CurrentValueSubject<Bool?, Never>
@@ -36,10 +35,10 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
init(session: MXSession, sessionInfo: UserSessionInfo) {
self.session = session
self.sessionInfo = sessionInfo
self.pusherEnabledSubject = CurrentValueSubject(nil)
self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false)
pusherEnabledSubject = CurrentValueSubject(nil)
remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false)
self.localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id)
localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id)
if let localNotificationSettings = localNotificationSettings, let isSilenced = localNotificationSettings[kMXAccountDataIsSilencedKey] as? Bool {
remotelyTogglingPushersAvailableSubject.send(true)
@@ -69,7 +68,7 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
// MARK: - Private
private func toggle(_ pusher: MXPusher, enabled: Bool) {
guard self.remotelyTogglingPushersAvailableSubject.value else {
guard remotelyTogglingPushersAvailableSubject.value else {
MXLog.warning("[UserSessionOverviewService] toggle pusher canceled: remotely toggling pushers not available")
return
}
@@ -77,20 +76,24 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
MXLog.debug("[UserSessionOverviewService] remotely toggling pusher")
let data = pusher.data.jsonDictionary() as? [String: Any] ?? [:]
self.session.matrixRestClient.setPusher(pushKey: pusher.pushkey,
kind: MXPusherKind(value: pusher.kind),
appId: pusher.appId,
appDisplayName:pusher.appDisplayName,
deviceDisplayName: pusher.deviceDisplayName,
profileTag: pusher.profileTag ?? "",
lang: pusher.lang,
data: data,
append: false,
enabled: enabled) { [weak self] response in
session.matrixRestClient.setPusher(pushKey: pusher.pushkey,
kind: MXPusherKind(value: pusher.kind),
appId: pusher.appId,
appDisplayName: pusher.appDisplayName,
deviceDisplayName: pusher.deviceDisplayName,
profileTag: pusher.profileTag ?? "",
lang: pusher.lang,
data: data,
append: false,
enabled: enabled) { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
if let account = MXKAccountManager.shared().activeAccounts.first, account.device?.deviceId == pusher.deviceId {
account.loadCurrentPusher(nil)
}
self.checkPusher()
case .failure(let error):
MXLog.warning("[UserSessionOverviewService] togglePusher failed due to error: \(error)")
@@ -18,18 +18,16 @@ import Combine
import Foundation
class MockUserSessionOverviewService: UserSessionOverviewServiceProtocol {
var pusherEnabledSubject: CurrentValueSubject<Bool?, Never>
var remotelyTogglingPushersAvailableSubject: CurrentValueSubject<Bool, Never>
init(pusherEnabled: Bool? = nil, remotelyTogglingPushersAvailable: Bool = true) {
self.pusherEnabledSubject = CurrentValueSubject(pusherEnabled)
self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable)
pusherEnabledSubject = CurrentValueSubject(pusherEnabled)
remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable)
}
func togglePushNotifications() {
guard let enabled = pusherEnabledSubject.value, self.remotelyTogglingPushersAvailableSubject.value else {
guard let enabled = pusherEnabledSubject.value, remotelyTogglingPushersAvailableSubject.value else {
return
}
@@ -20,7 +20,6 @@ import XCTest
@testable import RiotSwiftUI
class UserSessionOverviewViewModelTests: XCTestCase {
func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() {
let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService())
@@ -67,7 +67,7 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
case .viewSessionDetails:
completion?(.showSessionDetails(sessionInfo: sessionInfo))
case .togglePushNotifications:
self.state.showLoadingIndicator = true
state.showLoadingIndicator = true
service.togglePushNotifications()
case .renameSession:
completion?(.renameSession(sessionInfo))
@@ -57,8 +57,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).")
switch result {
case let .showOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
self.showOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter)
case let .showOtherSessions(sessionInfos: sessionInfos, filter: filter):
self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
case .verifyCurrentSession:
self.startVerifyCurrentSession()
case .renameSession(let sessionInfo):
@@ -69,6 +69,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
self.showCurrentSessionOverview(sessionInfo: sessionInfo)
case let .showUserSessionOverview(sessionInfo):
self.showUserSessionOverview(sessionInfo: sessionInfo)
case .linkDevice:
self.completion?(.linkDevice)
}
}
}
@@ -92,8 +94,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
loadingIndicator = nil
}
private func showOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
completion?(.openOtherSessions(sessionsInfo: sessionsInfo, filter: filter))
private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
completion?(.openOtherSessions(sessionInfos: sessionInfos, filter: filter))
}
private func startVerifyCurrentSession() {
@@ -107,5 +109,4 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
private func showUserSessionOverview(sessionInfo: UserSessionInfo) {
completion?(.openSessionOverview(sessionInfo: sessionInfo))
}
}
@@ -47,4 +47,10 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
func accountData(for eventType: String) -> [AnyHashable: Any]? {
session.accountData.accountData(forEventType: eventType)
}
func qrLoginAvailable() async throws -> Bool {
let service = QRLoginService(client: session.matrixRestClient,
mode: .authenticated)
return try await service.isServiceAvailable()
}
}
@@ -29,4 +29,6 @@ protocol UserSessionsDataProviderProtocol {
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo?
func accountData(for eventType: String) -> [AnyHashable: Any]?
func qrLoginAvailable() async throws -> Bool
}
@@ -24,6 +24,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private let dataProvider: UserSessionsDataProviderProtocol
private(set) var overviewData: UserSessionsOverviewData
private(set) var sessionInfos: [UserSessionInfo]
init(dataProvider: UserSessionsDataProviderProtocol) {
self.dataProvider = dataProvider
@@ -31,8 +32,9 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
otherSessions: [],
linkDeviceEnabled: false)
sessionInfos = []
setupInitialOverviewData()
}
@@ -42,8 +44,13 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
dataProvider.devices { response in
switch response {
case .success(let devices):
self.overviewData = self.sessionsOverviewData(from: devices)
completion(.success(self.overviewData))
self.sessionInfos = self.sortedSessionInfos(from: devices)
Task { @MainActor in
let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable()
self.overviewData = self.sessionsOverviewData(from: self.sessionInfos,
linkDeviceEnabled: linkDeviceEnabled ?? false)
completion(.success(self.overviewData))
}
case .failure(let error):
completion(.failure(error))
}
@@ -57,7 +64,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
return overviewData.otherSessions.first(where: { $0.id == sessionId })
}
// MARK: - Private
private func setupInitialOverviewData() {
@@ -68,7 +75,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [])
otherSessions: [],
linkDeviceEnabled: false)
}
private func getCurrentSessionInfo() -> UserSessionInfo? {
@@ -78,16 +86,20 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
}
return sessionInfo(from: device, isCurrentSession: true)
}
private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
let allSessions = devices
private func sortedSessionInfos(from devices: [MXDevice]) -> [UserSessionInfo] {
devices
.sorted { $0.lastSeenTs > $1.lastSeenTs }
.map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) }
return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified },
inactiveSessions: allSessions.filter { !$0.isActive },
otherSessions: allSessions.filter { !$0.isCurrent })
}
private func sessionsOverviewData(from allSessions: [UserSessionInfo],
linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified },
inactiveSessions: allSessions.filter { !$0.isActive },
otherSessions: allSessions.filter { !$0.isCurrent },
linkDeviceEnabled: linkDeviceEnabled)
}
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
@@ -28,6 +28,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private let mode: Mode
var overviewData: UserSessionsOverviewData
var sessionInfos = [UserSessionInfo]()
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
@@ -35,7 +36,8 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
otherSessions: [],
linkDeviceEnabled: false)
}
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
@@ -47,24 +49,28 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
otherSessions: [],
linkDeviceEnabled: false)
case .onlyUnverifiedSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions + [currentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions)
otherSessions: unverifiedSessions,
linkDeviceEnabled: false)
case .onlyInactiveSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions)
otherSessions: inactiveSessions,
linkDeviceEnabled: false)
default:
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions)
otherSessions: otherSessions,
linkDeviceEnabled: true)
}
completion(.success(overviewData))
@@ -73,7 +79,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
overviewData.otherSessions.first { $0.id == sessionId }
}
// MARK: - Private
private var currentSession: UserSessionInfo {
@@ -101,7 +107,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
deviceType: .desktop,
isVerified: verified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
@@ -21,10 +21,12 @@ struct UserSessionsOverviewData {
let unverifiedSessions: [UserSessionInfo]
let inactiveSessions: [UserSessionInfo]
let otherSessions: [UserSessionInfo]
let linkDeviceEnabled: Bool
}
protocol UserSessionsOverviewServiceProtocol {
var overviewData: UserSessionsOverviewData { get }
var sessionInfos: [UserSessionInfo] { get }
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) -> Void
@@ -23,6 +23,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
verifyLinkDeviceButtonStatus(true)
}
func testCurrentSessionVerified() {
@@ -30,6 +32,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
verifyLinkDeviceButtonStatus(true)
}
func testOnlyUnverifiedSessions() {
@@ -37,6 +41,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
verifyLinkDeviceButtonStatus(false)
}
func testOnlyInactiveSessions() {
@@ -44,6 +50,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
verifyLinkDeviceButtonStatus(false)
}
func testNoOtherSessions() {
@@ -51,5 +59,18 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists)
verifyLinkDeviceButtonStatus(false)
}
func verifyLinkDeviceButtonStatus(_ enabled: Bool) {
if enabled {
let linkDeviceButton = app.buttons["linkDeviceButton"]
XCTAssertTrue(linkDeviceButton.exists)
XCTAssertTrue(linkDeviceButton.isEnabled)
} else {
let linkDeviceButton = app.buttons["linkDeviceButton"]
XCTAssertFalse(linkDeviceButton.exists)
}
}
}
@@ -27,6 +27,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.linkDeviceButtonVisible)
}
func testLoadOnDidAppear() {
@@ -37,6 +38,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.linkDeviceButtonVisible)
}
func testSimpleActionProcessing() {
@@ -51,7 +53,10 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
XCTAssertEqual(result, .verifyCurrentSession)
viewModel.process(viewAction: .viewAllInactiveSessions)
XCTAssertEqual(result, .showOtherSessions(sessionsInfo: [], filter: .inactive))
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive))
viewModel.process(viewAction: .linkDevice)
XCTAssertEqual(result, .linkDevice)
}
func testShowSessionDetails() {
@@ -22,18 +22,20 @@ enum UserSessionsOverviewCoordinatorResult {
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case openSessionOverview(sessionInfo: UserSessionInfo)
case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
case linkDevice
}
// MARK: View model
enum UserSessionsOverviewViewModelResult: Equatable {
case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
case showOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
case verifyCurrentSession
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case showCurrentSessionOverview(sessionInfo: UserSessionInfo)
case showUserSessionOverview(sessionInfo: UserSessionInfo)
case linkDevice
}
// MARK: View
@@ -48,6 +50,8 @@ struct UserSessionsOverviewViewState: BindableState {
var otherSessionsViewData = [UserSessionListItemViewData]()
var showLoadingIndicator = false
var linkDeviceButtonVisible = false
}
enum UserSessionsOverviewViewAction {
@@ -60,4 +64,5 @@ enum UserSessionsOverviewViewAction {
case viewAllInactiveSessions
case viewAllOtherSessions
case tapUserSession(_ sessionId: String)
case linkDevice
}
@@ -58,8 +58,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
}
completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo))
case .viewAllUnverifiedSessions:
// TODO: showSessions(filteredBy: .unverified)
break
showSessions(filteredBy: .unverified)
case .viewAllInactiveSessions:
showSessions(filteredBy: .inactive)
case .viewAllOtherSessions:
@@ -71,6 +70,8 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
return
}
completion?(.showUserSessionOverview(sessionInfo: session))
case .linkDevice:
completion?(.linkDevice)
}
}
@@ -84,6 +85,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
if let currentSessionInfo = userSessionsViewData.currentSession {
state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo)
}
state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled
}
private func loadData() {
@@ -107,13 +109,13 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
}
private func showSessions(filteredBy filter: OtherUserSessionsFilter) {
completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.overviewData.otherSessions,
completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos,
filter: filter))
}
}
extension Collection where Element == UserSessionInfo {
func asViewData() -> [UserSessionListItemViewData] {
map { UserSessionListItemViewDataFactory().create(from: $0)}
map { UserSessionListItemViewDataFactory().create(from: $0) }
}
}
@@ -49,7 +49,7 @@ struct UserSessionListItem: View {
}
Text(viewData.sessionDetails)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.secondaryContent)
.foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent)
.multilineTextAlignment(.leading)
}
}
@@ -18,7 +18,6 @@ import Foundation
/// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable, Hashable {
var id: String {
sessionId
}
@@ -29,6 +28,8 @@ struct UserSessionListItemViewData: Identifiable, Hashable {
let sessionDetails: String
let highlightSessionDetails: Bool
let deviceAvatarViewData: DeviceAvatarViewData
let sessionDetailsIcon: String?
@@ -17,50 +17,50 @@
import Foundation
struct UserSessionListItemViewDataFactory {
func create(from session: UserSessionInfo) -> UserSessionListItemViewData {
let sessionName = UserSessionNameFormatter.sessionName(deviceType: session.deviceType,
sessionDisplayName: session.name)
let sessionDetails = buildSessionDetails(isVerified: session.isVerified,
lastActivityDate: session.lastSeenTimestamp,
isActive: session.isActive)
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: session.deviceType,
isVerified: session.isVerified)
return UserSessionListItemViewData(sessionId: session.id,
func create(from sessionInfo: UserSessionInfo, highlightSessionDetails: Bool = false) -> UserSessionListItemViewData {
let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType,
sessionDisplayName: sessionInfo.name)
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
isVerified: sessionInfo.isVerified)
return UserSessionListItemViewData(sessionId: sessionInfo.id,
sessionName: sessionName,
sessionDetails: sessionDetails,
highlightSessionDetails: highlightSessionDetails,
deviceAvatarViewData: deviceAvatarViewData,
sessionDetailsIcon: getSessionDetailsIcon(isActive: session.isActive))
sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive))
}
private func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?, isActive: Bool) -> String {
if isActive {
return activeSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate)
private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String {
if sessionInfo.isActive {
return activeSessionDetails(sessionInfo: sessionInfo)
} else {
return inactiveSessionDetails(lastActivityDate: lastActivityDate)
return inactiveSessionDetails(sessionInfo: sessionInfo)
}
}
private func inactiveSessionDetails(lastActivityDate: TimeInterval?) -> String {
if let lastActivityDate = lastActivityDate {
private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String {
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString)
}
return VectorL10n.userInactiveSessionItem
}
private func activeSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String {
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
let sessionDetailsString: String
let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
let sessionStatusText = sessionInfo.isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
var lastActivityDateString: String?
if let lastActivityDate = lastActivityDate {
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
}
if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
if sessionInfo.isCurrent {
sessionDetailsString = VectorL10n.userOtherSessionUnverifiedCurrentSessionDetails(sessionStatusText)
} else if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString)
} else {
sessionDetailsString = sessionStatusText
@@ -22,15 +22,25 @@ struct UserSessionsOverview: View {
@ObservedObject var viewModel: UserSessionsOverviewViewModel.Context
var body: some View {
ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
}
}
.readableFrame()
if viewModel.viewState.linkDeviceButtonVisible {
linkDeviceView
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
}
}
}
.background(theme.colors.system.ignoresSafeArea())
@@ -158,6 +168,23 @@ struct UserSessionsOverview: View {
}
.accessibilityIdentifier("userSessionsOverviewOtherSection")
}
/// The footer view containing link device button.
var linkDeviceView: some View {
VStack {
Button {
viewModel.send(viewAction: .linkDevice)
} label: {
Text(VectorL10n.userSessionsOverviewLinkDevice)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.padding(.top, 28)
.padding(.bottom, 12)
.padding(.horizontal, 16)
.accessibilityIdentifier("linkDeviceButton")
}
.background(theme.colors.system.ignoresSafeArea())
}
}
// MARK: - Previews
+2
View File
@@ -57,9 +57,11 @@ targets:
- path: ../Riot/Categories/UISearchBar.swift
- path: ../Riot/Categories/UIView.swift
- path: ../Riot/Categories/UIApplication.swift
- path: ../Riot/Categories/Codable.swift
- path: ../Riot/Assets/en.lproj/Vector.strings
- path: ../Riot/Modules/Analytics/AnalyticsScreen.swift
- path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift
- path: ../Riot/Modules/QRCode/QRCodeGenerator.swift
- path: ../Riot/Assets/en.lproj/Untranslated.strings
buildPhase: resources
- path: ../Riot/Assets/Images.xcassets
+2
View File
@@ -66,9 +66,11 @@ targets:
- path: ../Riot/Categories/UISearchBar.swift
- path: ../Riot/Categories/UIView.swift
- path: ../Riot/Categories/UIApplication.swift
- path: ../Riot/Categories/Codable.swift
- path: ../Riot/Assets/en.lproj/Vector.strings
- path: ../Riot/Modules/Analytics/AnalyticsScreen.swift
- path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift
- path: ../Riot/Modules/QRCode/QRCodeGenerator.swift
- path: ../Riot/Assets/en.lproj/Untranslated.strings
buildPhase: resources
- path: ../Riot/Assets/Images.xcassets