QR Code Login Flow (#2767)

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
This commit is contained in:
Mauro
2024-05-29 17:07:17 +02:00
committed by GitHub
parent 66a8f6f3d1
commit 4e23f7be22
72 changed files with 1241 additions and 226 deletions
@@ -444,10 +444,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
private func startAuthentication() {
let encryptionKeyProvider = EncryptionKeyProvider()
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore,
encryptionKeyProvider: EncryptionKeyProvider(),
encryptionKeyProvider: encryptionKeyProvider,
appSettings: appSettings)
let qrCodeLoginService = QRCodeLoginService(oidcConfiguration: appSettings.oidcConfiguration.rustValue,
encryptionKeyProvider: encryptionKeyProvider,
userSessionStore: userSessionStore)
authenticationFlowCoordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
@@ -14,6 +14,7 @@
// limitations under the License.
//
import AVFoundation
import UIKit
class AppMediator: AppMediatorProtocol {
@@ -64,4 +65,22 @@ class AppMediator: AppMediatorProtocol {
func setIdleTimerDisabled(_ disabled: Bool) {
application.isIdleTimerDisabled = disabled
}
func requestAuthorizationIfNeeded() async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .video)
// Determine if the user previously authorized camera access.
if status == .authorized {
return true
}
var isAuthorized = false
// If the system hasn't determined the user's authorization status,
// explicitly prompt them for approval.
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
}
return isAuthorized
}
}
@@ -33,6 +33,8 @@ protocol AppMediatorProtocol {
func openAppSettings()
func setIdleTimerDisabled(_ disabled: Bool)
func requestAuthorizationIfNeeded() async -> Bool
}
extension UIApplication.State: CustomStringConvertible {
@@ -154,6 +154,15 @@ final class AppSettings {
return url
}()
private(set) lazy var oidcConfiguration = OIDCConfigurationProxy(clientName: InfoPlistReader.main.bundleDisplayName,
redirectURI: oidcRedirectURL,
clientURI: websiteURL,
logoURI: logoURL,
tosURI: acceptableUseURL,
policyURI: privacyURL,
contacts: [supportEmailAddress],
staticRegistrations: oidcStaticRegistrations.mapKeys { $0.absoluteString })
/// A dictionary of accounts that have performed an initial sync through their proxy.
///
/// This is a temporary workaround. In the future we should be able to receive a signal from the
@@ -31,6 +31,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private let appSettings: AppSettings
private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
private let qrCodeLoginService: QRCodeLoginServiceProtocol
private var cancellables = Set<AnyCancellable>()
@@ -42,6 +43,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
weak var delegate: AuthenticationFlowCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
qrCodeLoginService: QRCodeLoginServiceProtocol,
bugReportService: BugReportServiceProtocol,
navigationRootCoordinator: NavigationRootCoordinator,
appMediator: AppMediatorProtocol,
@@ -55,6 +57,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
self.appSettings = appSettings
self.analytics = analytics
self.userIndicatorController = userIndicatorController
self.qrCodeLoginService = qrCodeLoginService
navigationStackCoordinator = NavigationStackCoordinator()
}
@@ -106,7 +109,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
private func startQRCodeLogin() {
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: QRCodeLoginService(),
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: qrCodeLoginService,
orientationManager: appMediator.windowManager,
appMediator: appMediator))
coordinator.actionsPublisher.sink { [weak self] action in
@@ -114,8 +117,18 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
return
}
switch action {
case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil)
Task { await self.startAuthentication() }
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
case .done(let userSession):
navigationStackCoordinator.setSheetCoordinator(nil)
// Since the qr code login flow includes verification
appSettings.hasRunIdentityConfirmationOnboarding = true
DispatchQueue.main.async {
self.userHasSignedIn(userSession: userSession)
}
}
}
.store(in: &cancellables)
@@ -87,9 +87,9 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
configureStateMachine()
stateMachine.tryEvent(.next)
rootNavigationStackCoordinator.setFullScreenCoverCoordinator(navigationStackCoordinator, animated: !isNewLogin)
stateMachine.tryEvent(.next)
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
@@ -134,6 +134,8 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
return .analyticsPrompt
case (.initial, false, false, false, true):
return .notificationPermissions
case (.initial, false, false, false, false):
return .finished
case (.identityConfirmation, _, _, _, _):
return .identityConfirmed
+32
View File
@@ -1221,6 +1221,38 @@ internal enum L10n {
internal static var screenQrCodeLoginDeviceCodeSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_device_code_subtitle") }
/// Enter the number below on your other device
internal static var screenQrCodeLoginDeviceCodeTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_device_code_title") }
/// Sign in to your other device and then try again, or use another device that’s already signed in.
internal static var screenQrCodeLoginDeviceNotSignedInScanStateDescription: String { return L10n.tr("Localizable", "screen_qr_code_login_device_not_signed_in_scan_state_description") }
/// Other device not signed in
internal static var screenQrCodeLoginDeviceNotSignedInScanStateSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_device_not_signed_in_scan_state_subtitle") }
/// The sign in was cancelled on the other device.
internal static var screenQrCodeLoginErrorCancelledSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_cancelled_subtitle") }
/// Sign in request cancelled
internal static var screenQrCodeLoginErrorCancelledTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_cancelled_title") }
/// The request on your other device was not accepted.
internal static var screenQrCodeLoginErrorDeclinedSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_declined_subtitle") }
/// Sign in declined
internal static var screenQrCodeLoginErrorDeclinedTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_declined_title") }
/// Sign in expired. Please try again.
internal static var screenQrCodeLoginErrorExpiredSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_expired_subtitle") }
/// The sign in was not completed in time
internal static var screenQrCodeLoginErrorExpiredTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_expired_title") }
/// Your other device does not support signing in to %@ with a QR code.
///
/// Try signing in manually, or scan the QR code with another device.
internal static func screenQrCodeLoginErrorLinkingNotSuportedSubtitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_qr_code_login_error_linking_not_suported_subtitle", String(describing: p1))
}
/// QR code not supported
internal static var screenQrCodeLoginErrorLinkingNotSuportedTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_linking_not_suported_title") }
/// Your account provider does not support %1$@.
internal static func screenQrCodeLoginErrorSlidingSyncNotSupportedSubtitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_qr_code_login_error_sliding_sync_not_supported_subtitle", String(describing: p1))
}
/// %1$@ not supported
internal static func screenQrCodeLoginErrorSlidingSyncNotSupportedTitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_qr_code_login_error_sliding_sync_not_supported_title", String(describing: p1))
}
/// Ready to scan
internal static var screenQrCodeLoginInitialStateButtonTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_button_title") }
/// Open %1$@ on a desktop device
+2 -1
View File
@@ -17,10 +17,11 @@
import UIKit
extension AppMediatorMock {
static var `default`: AppMediatorProtocol {
static var `default`: AppMediatorMock {
let mock = AppMediatorMock()
mock.underlyingAppState = .active
mock.requestAuthorizationIfNeededUnderlyingReturnValue = true
mock.underlyingWindowManager = WindowManagerMock()
return mock
@@ -1014,6 +1014,70 @@ class AppMediatorMock: AppMediatorProtocol {
setIdleTimerDisabledReceivedInvocations.append(disabled)
setIdleTimerDisabledClosure?(disabled)
}
//MARK: - requestAuthorizationIfNeeded
var requestAuthorizationIfNeededUnderlyingCallsCount = 0
var requestAuthorizationIfNeededCallsCount: Int {
get {
if Thread.isMainThread {
return requestAuthorizationIfNeededUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = requestAuthorizationIfNeededUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
requestAuthorizationIfNeededUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
requestAuthorizationIfNeededUnderlyingCallsCount = newValue
}
}
}
}
var requestAuthorizationIfNeededCalled: Bool {
return requestAuthorizationIfNeededCallsCount > 0
}
var requestAuthorizationIfNeededUnderlyingReturnValue: Bool!
var requestAuthorizationIfNeededReturnValue: Bool! {
get {
if Thread.isMainThread {
return requestAuthorizationIfNeededUnderlyingReturnValue
} else {
var returnValue: Bool? = nil
DispatchQueue.main.sync {
returnValue = requestAuthorizationIfNeededUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
requestAuthorizationIfNeededUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
requestAuthorizationIfNeededUnderlyingReturnValue = newValue
}
}
}
}
var requestAuthorizationIfNeededClosure: (() async -> Bool)?
func requestAuthorizationIfNeeded() async -> Bool {
requestAuthorizationIfNeededCallsCount += 1
if let requestAuthorizationIfNeededClosure = requestAuthorizationIfNeededClosure {
return await requestAuthorizationIfNeededClosure()
} else {
return requestAuthorizationIfNeededReturnValue
}
}
}
class AudioConverterMock: AudioConverterProtocol {
@@ -7203,18 +7267,23 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol {
}
}
class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
get { return underlyingQrLoginProgressPublisher }
set(value) { underlyingQrLoginProgressPublisher = value }
}
var underlyingQrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never>!
//MARK: - requestAuthorizationIfNeeded
//MARK: - loginWithQRCode
var requestAuthorizationIfNeededUnderlyingCallsCount = 0
var requestAuthorizationIfNeededCallsCount: Int {
var loginWithQRCodeDataUnderlyingCallsCount = 0
var loginWithQRCodeDataCallsCount: Int {
get {
if Thread.isMainThread {
return requestAuthorizationIfNeededUnderlyingCallsCount
return loginWithQRCodeDataUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = requestAuthorizationIfNeededUnderlyingCallsCount
returnValue = loginWithQRCodeDataUnderlyingCallsCount
}
return returnValue!
@@ -7222,27 +7291,29 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol {
}
set {
if Thread.isMainThread {
requestAuthorizationIfNeededUnderlyingCallsCount = newValue
loginWithQRCodeDataUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
requestAuthorizationIfNeededUnderlyingCallsCount = newValue
loginWithQRCodeDataUnderlyingCallsCount = newValue
}
}
}
}
var requestAuthorizationIfNeededCalled: Bool {
return requestAuthorizationIfNeededCallsCount > 0
var loginWithQRCodeDataCalled: Bool {
return loginWithQRCodeDataCallsCount > 0
}
var loginWithQRCodeDataReceivedData: Data?
var loginWithQRCodeDataReceivedInvocations: [Data] = []
var requestAuthorizationIfNeededUnderlyingReturnValue: Bool!
var requestAuthorizationIfNeededReturnValue: Bool! {
var loginWithQRCodeDataUnderlyingReturnValue: Result<UserSessionProtocol, QRCodeLoginServiceError>!
var loginWithQRCodeDataReturnValue: Result<UserSessionProtocol, QRCodeLoginServiceError>! {
get {
if Thread.isMainThread {
return requestAuthorizationIfNeededUnderlyingReturnValue
return loginWithQRCodeDataUnderlyingReturnValue
} else {
var returnValue: Bool? = nil
var returnValue: Result<UserSessionProtocol, QRCodeLoginServiceError>? = nil
DispatchQueue.main.sync {
returnValue = requestAuthorizationIfNeededUnderlyingReturnValue
returnValue = loginWithQRCodeDataUnderlyingReturnValue
}
return returnValue!
@@ -7250,22 +7321,24 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol {
}
set {
if Thread.isMainThread {
requestAuthorizationIfNeededUnderlyingReturnValue = newValue
loginWithQRCodeDataUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
requestAuthorizationIfNeededUnderlyingReturnValue = newValue
loginWithQRCodeDataUnderlyingReturnValue = newValue
}
}
}
}
var requestAuthorizationIfNeededClosure: (() async -> Bool)?
var loginWithQRCodeDataClosure: ((Data) async -> Result<UserSessionProtocol, QRCodeLoginServiceError>)?
func requestAuthorizationIfNeeded() async -> Bool {
requestAuthorizationIfNeededCallsCount += 1
if let requestAuthorizationIfNeededClosure = requestAuthorizationIfNeededClosure {
return await requestAuthorizationIfNeededClosure()
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
loginWithQRCodeDataCallsCount += 1
loginWithQRCodeDataReceivedData = data
loginWithQRCodeDataReceivedInvocations.append(data)
if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure {
return await loginWithQRCodeDataClosure(data)
} else {
return requestAuthorizationIfNeededReturnValue
return loginWithQRCodeDataReturnValue
}
}
}
@@ -1,28 +0,0 @@
//
// Copyright 2024 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 QRCodeLoginServiceMockConfiguration {
var isAuthorized = true
}
extension QRCodeLoginServiceMock {
convenience init(configuration: QRCodeLoginServiceMockConfiguration) {
self.init()
requestAuthorizationIfNeededReturnValue = configuration.isAuthorized
}
}
@@ -0,0 +1,156 @@
//
// Copyright 2024 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.
//
// Helpers to remove ECI headers from QR Code raw data
// https://gist.github.com/PetrusM/267e2ee8c1d8b5dca17eac085afa7d7c
import AVKit
import Foundation
extension AVMetadataMachineReadableCodeObject {
var binaryValue: Data? {
switch type {
case .qr:
guard let binaryValueWithProtocol,
let symbolVersion = (descriptor as? CIQRCodeDescriptor)?.symbolVersion else {
return nil
}
MXLog.info("QR code raw data: \(binaryValueWithProtocol.map { String(format: "%02x", $0) }.joined())")
MXLog.info("QR code symbol version: \(symbolVersion)")
return Self.removeQrProtocolData(binaryValueWithProtocol, symbolVersion: symbolVersion)
case .aztec:
guard let string = stringValue
else { return nil }
return string.data(using: String.Encoding.isoLatin1)
default:
return nil
}
}
var binaryValueWithProtocol: Data? {
guard let descriptor else {
return nil
}
switch type {
case .qr:
return (descriptor as? CIQRCodeDescriptor)?.errorCorrectedPayload
case .aztec:
return (descriptor as? CIAztecCodeDescriptor)?.errorCorrectedPayload
case .pdf417:
return (descriptor as? CIPDF417CodeDescriptor)?.errorCorrectedPayload
case .dataMatrix:
return (descriptor as? CIDataMatrixCodeDescriptor)?.errorCorrectedPayload
default:
return nil
}
}
static func removeQrProtocolData(_ input: Data, symbolVersion: Int) -> Data? {
var halves = input.halfBytes()
var batch = takeBatch(&halves, version: symbolVersion)
var output = batch
while !batch.isEmpty {
batch = takeBatch(&halves, version: symbolVersion)
output.append(contentsOf: batch)
}
let data = Data(output)
MXLog.info("QR code decoded binary data: \(data.map { String(format: "%02x", $0) }.joined())")
return data
}
private static func takeBatch(_ input: inout [HalfByte], version: Int) -> [UInt8] {
let characterCountLength = version > 9 ? 16 : 8
let mode = input.remove(at: 0)
var output = [UInt8]()
switch mode.value {
// If there is not only binary in the QRCode, then cases should be added here.
case 0x04: // Binary
let charactersCount: UInt16
if characterCountLength == 8 {
charactersCount = UInt16(input.takeUInt8())
} else {
charactersCount = UInt16(input.takeUInt16())
}
for _ in 0..<charactersCount {
output.append(input.takeUInt8())
}
return output
case 0x00: // End of data
return []
default:
return []
}
}
}
private struct HalfByte {
let value: UInt8
}
private extension [HalfByte] {
mutating func takeUInt8() -> UInt8 {
let left = remove(at: 0)
let right = remove(at: 0)
return UInt8(left, right)
}
mutating func takeUInt16() -> UInt16 {
let first = remove(at: 0)
let second = remove(at: 0)
let third = remove(at: 0)
let fourth = remove(at: 0)
return UInt16(first, second, third, fourth)
}
}
private extension Data {
func halfBytes() -> [HalfByte] {
var result = [HalfByte]()
forEach { (byte: UInt8) in
result.append(contentsOf: byte.halfBytes())
}
return result
}
init(_ halves: [HalfByte]) {
var halves = halves
var result = [UInt8]()
while halves.count > 1 {
result.append(halves.takeUInt8())
}
self.init(result)
}
}
private extension UInt8 {
func halfBytes() -> [HalfByte] {
[HalfByte(value: self >> 4), HalfByte(value: self & 0x0F)]
}
init(_ left: HalfByte, _ right: HalfByte) {
self.init((left.value << 4) + (right.value & 0x0F))
}
}
private extension UInt16 {
init(_ first: HalfByte, _ second: HalfByte, _ third: HalfByte, _ fourth: HalfByte) {
let first = UInt16(first.value) << 12
let second = UInt16(second.value) << 8
let third = UInt16(third.value) << 4
let fourth = UInt16(fourth.value) & 0x0F
let result = first + second + third + fourth
self.init(result)
}
}
@@ -0,0 +1,42 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
extension Data {
init?(hexString: String) {
self.init()
var hex = hexString
// If the hex string has an odd length, prepend a zero
if hex.count % 2 != 0 {
hex = "0" + hex
}
for i in stride(from: 0, to: hex.count, by: 2) {
let startIndex = hex.index(hex.startIndex, offsetBy: i)
let endIndex = hex.index(hex.startIndex, offsetBy: i + 2)
let byteString = String(hex[startIndex..<endIndex])
if let byte = UInt8(byteString, radix: 16) {
append(byte)
} else {
return nil
}
}
}
}
@@ -31,9 +31,13 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
self.appSettings = appSettings
super.init(initialViewState: AuthenticationStartScreenViewState())
if !ProcessInfo.processInfo.isiOSAppOnMac {
appSettings.$qrCodeLoginEnabled
.weakAssign(to: \.state.isQRCodeLoginEnabled, on: self)
.store(in: &cancellables)
if appSettings.isDevelopmentBuild {
state.isQRCodeLoginEnabled = true
} else {
appSettings.$qrCodeLoginEnabled
.weakAssign(to: \.state.isQRCodeLoginEnabled, on: self)
.store(in: &cancellables)
}
}
}
@@ -27,6 +27,8 @@ struct QRCodeLoginScreenCoordinatorParameters {
enum QRCodeLoginScreenCoordinatorAction {
case cancel
case signInManually
case done(userSession: UserSessionProtocol)
}
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
@@ -52,8 +54,12 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .signInManually:
self.actionsSubject.send(.signInManually)
case .cancel:
self.actionsSubject.send(.cancel)
case .done(let userSession):
self.actionsSubject.send(.done(userSession: userSession))
}
}
.store(in: &cancellables)
@@ -18,6 +18,8 @@ import Foundation
enum QRCodeLoginScreenViewModelAction {
case cancel
case signInManually
case done(userSession: UserSessionProtocol)
}
struct QRCodeLoginScreenViewState: BindableState {
@@ -44,11 +46,18 @@ struct QRCodeLoginScreenViewState: BindableState {
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem2),
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem3)
]
var bindings = QRCodeLoginScreenViewStateBindings()
}
struct QRCodeLoginScreenViewStateBindings {
var qrResult: Data?
}
enum QRCodeLoginScreenViewAction {
case cancel
case startScan
case signInManually
case openSettings
}
@@ -65,6 +74,11 @@ enum QRCodeLoginState: Equatable {
enum QRCodeLoginErrorState: Equatable {
case noCameraPermission
case connectionNotSecure
case cancelled
case declined
case expired
case linkingNotSupported
case deviceNotSupported
case unknown
}
@@ -75,6 +89,8 @@ enum QRCodeLoginState: Equatable {
case connecting
/// the qr code has been processed and is invalid
case invalid
/// the qr code has been processed but it belongs to a device not signed in,
case deviceNotSignedIn
}
enum QRCodeLoginDisplayCodeState: Equatable {
@@ -99,4 +115,42 @@ enum QRCodeLoginState: Equatable {
return false
}
}
var isScanning: Bool {
switch self {
case .scan(let state):
return state == .scanning
default:
return false
}
}
var isError: Bool {
switch self {
case .error:
return true
case let .scan(state):
switch state {
case .invalid, .deviceNotSignedIn:
return true
default:
return false
}
default:
return false
}
}
var shouldDisplayCancelButton: Bool {
switch self {
case .initial:
return true
case .scan:
return true
case .error(let error):
return error == .noCameraPermission
default:
return false
}
}
}
@@ -14,9 +14,8 @@
// limitations under the License.
//
import AVFoundation
import Combine
import SwiftUI
import Foundation
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
@@ -28,12 +27,15 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var scanTask: Task<Void, Never>?
init(qrCodeLoginService: QRCodeLoginServiceProtocol,
appMediator: AppMediatorProtocol) {
self.qrCodeLoginService = qrCodeLoginService
self.appMediator = appMediator
super.init(initialViewState: QRCodeLoginScreenViewState())
setupSubscriptions()
}
// MARK: - Public
@@ -46,16 +48,109 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
Task { await startScanIfPossible() }
case .openSettings:
appMediator.openAppSettings()
case .signInManually:
actionsSubject.send(.signInManually)
}
}
private func startScanIfPossible() async {
state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
// MARK: - Private
private func setupSubscriptions() {
context.$viewState
// not using compactMap before remove duplicates because if there is an error, and the same code needs to be rescanned the transition to nil to clean the state would get ignored.
.map(\.bindings.qrResult)
.removeDuplicates()
.compactMap { $0 }
// this needs to be received on the main actor or the state change for connecting won't work properly
.receive(on: DispatchQueue.main)
.sink { [weak self] qrData in
self?.handleScan(qrData: qrData)
}
.store(in: &cancellables)
qrCodeLoginService.qrLoginProgressPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] progress in
MXLog.info("QR Login Progress changed to: \(progress)")
guard let self,
// Let's not advance the state if the current state is already invalid
!state.state.isError else {
return
}
switch progress {
case .establishingSecureChannel(_, let stringCode):
state.state = .displayCode(.deviceCode(stringCode))
case .waitingForToken(let code):
state.state = .displayCode(.verificationCode(code))
default:
break
}
}
.store(in: &cancellables)
}
private func startScanIfPossible() async {
state.bindings.qrResult = nil
state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
}
private func handleScan(qrData: Data) {
guard scanTask == nil else {
return
}
state.state = .scan(.connecting)
scanTask = Task { [weak self] in
guard let self else {
return
}
defer {
scanTask = nil
}
MXLog.info("Scanning QR code: \(qrData)")
switch await qrCodeLoginService.loginWithQRCode(data: qrData) {
case let .success(session):
MXLog.info("QR Login completed")
actionsSubject.send(.done(userSession: session))
case .failure(let error):
handleError(error: error)
}
}
}
private func handleError(error: QRCodeLoginServiceError) {
MXLog.error("Failed to scan the QR code: \(error)")
switch error {
case .invalidQRCode:
state.state = .scan(.invalid)
case .deviceNotSignedIn:
state.state = .scan(.deviceNotSignedIn)
case .cancelled:
state.state = .error(.cancelled)
case .connectionInsecure:
state.state = .error(.connectionNotSecure)
case .declined:
state.state = .error(.declined)
case .linkingNotSupported:
state.state = .error(.linkingNotSupported)
case .expired:
state.state = .error(.expired)
case .deviceNotSupported:
state.state = .error(.deviceNotSupported)
case .failedLoggingIn, .unknown:
state.state = .error(.unknown)
}
}
/// Only for mocking initial states
fileprivate init(state: QRCodeLoginState) {
qrCodeLoginService = QRCodeLoginServiceMock(configuration: .init())
qrCodeLoginService = QRCodeLoginServiceMock()
appMediator = AppMediatorMock.default
super.init(initialViewState: .init(state: state))
}
@@ -62,7 +62,7 @@ struct QRCodeLoginScreen: View {
SFNumberedListView(items: context.viewState.initialStateListItems)
}
} bottomContent: {
Button(L10n.actionContinue) {
Button(L10n.screenQrCodeLoginInitialStateButtonTitle) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
@@ -178,8 +178,11 @@ struct QRCodeLoginScreen: View {
}
.buttonStyle(.compound(.primary))
VStack(spacing: 0) {
Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle, icon: \.error, iconSize: .medium, relativeTo: .compound.bodyMDSemibold)
VStack(spacing: 4) {
Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle,
icon: \.error,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
@@ -187,6 +190,29 @@ struct QRCodeLoginScreen: View {
Text(L10n.screenQrCodeLoginInvalidScanStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
}
}
case .deviceNotSignedIn:
VStack(spacing: 16) {
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
VStack(spacing: 4) {
Label(L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle,
icon: \.error,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
Text(L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
}
}
}
@@ -194,7 +220,7 @@ struct QRCodeLoginScreen: View {
}
private var qrScanner: some View {
QRCodeScannerView()
QRCodeScannerView(result: $context.qrResult, isScanning: context.viewState.state.isScanning)
.aspectRatio(1.0, contentMode: .fill)
.frame(maxWidth: 312)
.readFrame($qrFrame)
@@ -207,7 +233,7 @@ struct QRCodeLoginScreen: View {
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
if !context.viewState.state.isDisplayingCode {
if context.viewState.state.shouldDisplayCancelButton {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
@@ -273,25 +299,65 @@ struct QRCodeLoginScreen: View {
SFNumberedListView(items: context.viewState.connectionNotSecureListItems)
}
}
default:
simpleErrorStack(errorState: errorState)
}
}
@ViewBuilder
private func simpleErrorStack(errorState: QRCodeLoginState.QRCodeLoginErrorState) -> some View {
let title = switch errorState {
case .cancelled:
L10n.screenQrCodeLoginErrorCancelledTitle
case .declined:
L10n.screenQrCodeLoginErrorDeclinedTitle
case .expired:
L10n.screenQrCodeLoginErrorExpiredTitle
case .linkingNotSupported:
L10n.screenQrCodeLoginErrorLinkingNotSuportedTitle
case .deviceNotSupported:
L10n.screenQrCodeLoginErrorSlidingSyncNotSupportedTitle(InfoPlistReader.main.bundleDisplayName)
case .unknown:
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
L10n.commonSomethingWentWrong
default:
fatalError("This should not be displayed")
}
let subtitle: String = switch errorState {
case .cancelled:
L10n.screenQrCodeLoginErrorCancelledSubtitle
case .declined:
L10n.screenQrCodeLoginErrorDeclinedSubtitle
case .expired:
L10n.screenQrCodeLoginErrorExpiredSubtitle
case .linkingNotSupported:
L10n.screenQrCodeLoginErrorLinkingNotSuportedSubtitle(InfoPlistReader.main.bundleDisplayName)
case .deviceNotSupported:
L10n.screenQrCodeLoginErrorSlidingSyncNotSupportedSubtitle(InfoPlistReader.main.bundleDisplayName)
case .unknown:
L10n.screenQrCodeLoginUnknownErrorDescription
default:
fatalError("This should not be displayed")
}
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
VStack(spacing: 8) {
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Text(L10n.commonSomethingWentWrong)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginUnknownErrorDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
Text(subtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
}
@ViewBuilder
private func errorContentFooter(errorState: QRCodeLoginState.QRCodeLoginErrorState) -> some View {
switch errorState {
case .noCameraPermission:
@@ -299,11 +365,30 @@ struct QRCodeLoginScreen: View {
context.send(viewAction: .openSettings)
}
.buttonStyle(.compound(.primary))
case .connectionNotSecure, .unknown:
case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported:
Button(L10n.screenQrCodeLoginStartOverButton) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
case .cancelled:
Button(L10n.actionTryAgain) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
case .linkingNotSupported:
VStack(spacing: 16) {
Button(L10n.screenOnboardingSignInManually) {
context.send(viewAction: .signInManually)
}
.buttonStyle(.compound(.primary))
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
.padding(.vertical, 13)
.frame(maxWidth: .infinity)
.buttonStyle(.compound(.plain))
}
}
}
}
@@ -336,24 +421,40 @@ private struct QRScannerViewOverlay: View {
// MARK: - Previews
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
// Initial
static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial)
// Scanning
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting))
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid))
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.deviceNotSignedIn))
// Display Code
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
// Errors
static let noCameraPermissionStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.noCameraPermission))
static let connectionNotSecureStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.connectionNotSecure))
static let linkingUnsupportedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.linkingNotSupported))
static let cancelledStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.cancelled))
static let declinedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined))
static let expiredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.expired))
static let deviceNoSupportedViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.deviceNotSupported))
static let unknownErrorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.unknown))
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
static var previews: some View {
QRCodeLoginScreen(context: initialStateViewModel.context)
.previewDisplayName("Initial")
@@ -367,19 +468,37 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
QRCodeLoginScreen(context: invalidStateViewModel.context)
.previewDisplayName("Invalid")
QRCodeLoginScreen(context: noCameraPermissionStateViewModel.context)
.previewDisplayName("No Camera Permission")
QRCodeLoginScreen(context: connectionNotSecureStateViewModel.context)
.previewDisplayName("Connection not secure")
QRCodeLoginScreen(context: unknownErrorStateViewModel.context)
.previewDisplayName("Unknown error")
QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context)
.previewDisplayName("Device not signed in")
QRCodeLoginScreen(context: deviceCodeStateViewModel.context)
.previewDisplayName("Device code")
QRCodeLoginScreen(context: verificationCodeStateViewModel.context)
.previewDisplayName("Verification code")
QRCodeLoginScreen(context: noCameraPermissionStateViewModel.context)
.previewDisplayName("No Camera Permission")
QRCodeLoginScreen(context: connectionNotSecureStateViewModel.context)
.previewDisplayName("Connection not secure")
QRCodeLoginScreen(context: linkingUnsupportedStateViewModel.context)
.previewDisplayName("Linking unsupported")
QRCodeLoginScreen(context: cancelledStateViewModel.context)
.previewDisplayName("Cancelled")
QRCodeLoginScreen(context: declinedStateViewModel.context)
.previewDisplayName("Declined")
QRCodeLoginScreen(context: expiredStateViewModel.context)
.previewDisplayName("Expired")
QRCodeLoginScreen(context: deviceNoSupportedViewModel.context)
.previewDisplayName("Device not supported")
QRCodeLoginScreen(context: unknownErrorStateViewModel.context)
.previewDisplayName("Unknown error")
}
}
@@ -19,13 +19,48 @@ import SwiftUI
import UIKit
struct QRCodeScannerView: UIViewControllerRepresentable {
@Binding var result: Data?
var isScanning: Bool
func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController()
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_ uiViewController: QRScannerController, context: Context) { }
func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {
if isScanning {
uiViewController.startScan()
} else {
uiViewController.stopScan()
}
}
func makeCoordinator() -> Coordinator {
Coordinator($result)
}
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
@Binding var scanResult: Data?
init(_ scanResult: Binding<Data?>) {
_scanResult = scanResult
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
// Check if the metadataObjects array is not nil and it contains at least one object.
guard metadataObjects.count > 0,
let metadataObj = metadataObjects[0] as? AVMetadataMachineReadableCodeObject,
metadataObj.type == AVMetadataObject.ObjectType.qr,
let data = metadataObj.binaryValue else {
MXLog.info("QRCodeScannerView: invalid qr scan")
return
}
scanResult = data
MXLog.info("QRCodeScannerView: scanned data")
}
}
}
final class QRScannerController: UIViewController {
@@ -33,8 +68,8 @@ final class QRScannerController: UIViewController {
private var videoPreviewLayer: AVCaptureVideoPreviewLayer?
private var qrCodeFrameView: UIView?
var delegate: AVCaptureMetadataOutputObjectsDelegate?
weak var delegate: AVCaptureMetadataOutputObjectsDelegate?
override func viewDidLoad() {
super.viewDidLoad()
@@ -73,15 +108,26 @@ final class QRScannerController: UIViewController {
videoPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
videoPreviewLayer?.frame = view.layer.bounds
view.layer.addSublayer(previewLayer)
// Start video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
videoPreviewLayer?.frame = view.layer.bounds
}
func startScan() {
// Start video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
MXLog.info("QRCodeScannerView: capture session started")
}
}
func stopScan() {
// Stop video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.stopRunning()
MXLog.info("QRCodeScannerView: capture session stopped")
}
}
}
@@ -39,12 +39,6 @@ struct DeveloperOptionsScreen: View {
}
}
Section("QR Code") {
Toggle(isOn: $context.qrCodeLoginEnabled) {
Text("QR code login")
}
}
Section("Room") {
Toggle(isOn: $context.shouldCollapseRoomStateEvents) {
Text("Collapse room state events")
@@ -32,22 +32,13 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultHomeserverAddress,
loginMode: .unknown))
let oidcConfiguration = OidcConfiguration(clientName: InfoPlistReader.main.bundleDisplayName,
redirectUri: appSettings.oidcRedirectURL.absoluteString,
clientUri: appSettings.websiteURL.absoluteString,
logoUri: appSettings.logoURL.absoluteString,
tosUri: appSettings.acceptableUseURL.absoluteString,
policyUri: appSettings.privacyURL.absoluteString,
contacts: [appSettings.supportEmailAddress],
staticRegistrations: appSettings.oidcStaticRegistrations.mapKeys { $0.absoluteString })
authenticationService = AuthenticationService(basePath: userSessionStore.baseDirectory.path,
passphrase: passphrase,
userAgent: UserAgentBuilder.makeASCIIUserAgent(),
additionalRootCertificates: [],
proxy: appSettings.websiteURL.globalProxy,
oidcConfiguration: oidcConfiguration,
oidcConfiguration: appSettings.oidcConfiguration.rustValue,
customSlidingSyncProxy: appSettings.slidingSyncProxyURL?.absoluteString,
sessionDelegate: userSessionStore.clientSessionDelegate,
crossProcessRefreshLockId: InfoPlistReader.main.bundleIdentifier)
@@ -42,7 +42,7 @@ enum AuthenticationServiceError: Error {
protocol AuthenticationServiceProxyProtocol {
/// The currently configured homeserver.
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { get }
/// Sets up the service for login on the specified homeserver address.
func configure(for homeserverAddress: String) async -> Result<Void, AuthenticationServiceError>
/// Performs login using OIDC for the current homeserver.
@@ -0,0 +1,45 @@
//
// Copyright 2024 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 OIDCConfigurationProxy {
let clientName: String
let redirectURI: URL
let clientURI: URL
let logoURI: URL
let tosURI: URL
let policyURI: URL
let contacts: [String]
let staticRegistrations: [String: String]
}
#if canImport(MatrixRustSDK)
import MatrixRustSDK
extension OIDCConfigurationProxy {
var rustValue: OidcConfiguration {
OidcConfiguration(clientName: clientName,
redirectUri: redirectURI.absoluteString,
clientUri: clientURI.absoluteString,
logoUri: logoURI.absoluteString,
tosUri: tosURI.absoluteString,
policyUri: policyURI.absoluteString,
contacts: contacts,
staticRegistrations: staticRegistrations)
}
}
#endif
@@ -1,37 +0,0 @@
//
// Copyright 2024 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
final class QRCodeLoginService: QRCodeLoginServiceProtocol {
func requestAuthorizationIfNeeded() async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .video)
// Determine if the user previously authorized camera access.
if status == .authorized {
return true
}
var isAuthorized = false
// If the system hasn't determined the user's authorization status,
// explicitly prompt them for approval.
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
}
return isAuthorized
}
}
@@ -0,0 +1,118 @@
//
// Copyright 2024 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 MatrixRustSDK
final class QRCodeLoginService: QRCodeLoginServiceProtocol {
private let oidcConfiguration: OidcConfiguration
private let passphrase: String
private let userSessionStore: UserSessionStoreProtocol
private let qrLoginProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
qrLoginProgressSubject.eraseToAnyPublisher()
}
init(oidcConfiguration: OidcConfiguration,
encryptionKeyProvider: EncryptionKeyProviderProtocol,
userSessionStore: UserSessionStoreProtocol) {
self.oidcConfiguration = oidcConfiguration
self.userSessionStore = userSessionStore
passphrase = encryptionKeyProvider.generateKey().base64EncodedString()
}
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
let qrData: QrCodeData
do {
qrData = try QrCodeData.fromBytes(bytes: data)
} catch {
MXLog.error("QRCode decode error: \(error)")
return .failure(.invalidQRCode)
}
let listener = QrLoginProgressListenerProxy { [weak self] progress in
self?.qrLoginProgressSubject.send(progress)
}
do {
let client = try await ClientBuilder()
.basePath(path: userSessionStore.baseDirectory.path(percentEncoded: false))
.passphrase(passphrase: passphrase)
.userAgent(userAgent: UserAgentBuilder.makeASCIIUserAgent())
.enableCrossProcessRefreshLock(processId: InfoPlistReader.main.bundleIdentifier,
sessionDelegate: userSessionStore.clientSessionDelegate)
.serverVersions(versions: ["v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5"]) // FIXME: Quick and dirty fix for stopping version requests on startup https://github.com/matrix-org/matrix-rust-sdk/pull/1376
.buildWithQrCode(qrCodeData: qrData, oidcConfiguration: oidcConfiguration, progressListener: listener)
return await login(client: client)
} catch let error as HumanQrLoginError {
MXLog.error("QRCode login error: \(error)")
return .failure(error.serviceError)
} catch {
MXLog.error("QRCode login unknown error: \(error)")
return .failure(.unknown)
}
}
private func login(client: Client) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
switch await userSessionStore.userSession(for: client, passphrase: passphrase) {
case .success(let session):
return .success(session)
case .failure(let error):
MXLog.error("QRCode login failed error: \(error)")
return .failure(.failedLoggingIn)
}
}
}
final class QrLoginProgressListenerProxy: QrLoginProgressListener {
private let onUpdateClosure: (QrLoginProgress) -> Void
init(onUpdateClosure: @escaping (QrLoginProgress) -> Void) {
self.onUpdateClosure = onUpdateClosure
}
func onUpdate(state: QrLoginProgress) {
onUpdateClosure(state)
}
}
private extension HumanQrLoginError {
var serviceError: QRCodeLoginServiceError {
switch self {
case .Cancelled:
return .cancelled
case .ConnectionInsecure:
return .connectionInsecure
case .Declined:
return .declined
case .LinkingNotSupported:
return .linkingNotSupported
case .Expired:
return .expired
case .InvalidQrCode:
return .invalidQRCode
case .SlidingSyncNotAvailable:
return .deviceNotSupported
case .OtherDeviceNotSignedIn:
return .deviceNotSignedIn
case .Unknown, .OidcMetadataInvalid:
return .unknown
}
}
}
@@ -14,7 +14,27 @@
// limitations under the License.
//
import Combine
import Foundation
import MatrixRustSDK
enum QRCodeLoginServiceError: Error {
case failedLoggingIn
case invalidQRCode
case cancelled
case connectionInsecure
case declined
case linkingNotSupported
case expired
case deviceNotSupported
case deviceNotSignedIn
case unknown
}
// sourcery: AutoMockable
protocol QRCodeLoginServiceProtocol {
func requestAuthorizationIfNeeded() async -> Bool
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> { get }
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, QRCodeLoginServiceError>
}
@@ -130,6 +130,7 @@ class MockScreen: Identifiable {
return navigationStackCoordinator
case .authenticationFlow:
let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: MockAuthenticationServiceProxy(),
qrCodeLoginService: QRCodeLoginServiceMock(),
bugReportService: BugReportServiceMock(),
navigationRootCoordinator: navigationRootCoordinator,
appMediator: AppMediatorMock.default,