Add support for linking new devices in the QRCodeLoginScreen. (#4891)

* Adds the remaining parts for showing/scanning a QR code to link a new device.

* Refactor the QRCodeLoginService to work the same way as the LinkNewDeviceService.
This commit is contained in:
Doug
2026-01-07 12:18:39 +00:00
committed by GitHub
parent 9269f3057b
commit c1bb838517
62 changed files with 959 additions and 388 deletions
@@ -298,7 +298,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - QR Code // MARK: - QR Code
private func showQRCodeLoginScreen() { private func showQRCodeLoginScreen() {
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: authenticationService, let stackCoordinator = NavigationStackCoordinator()
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(mode: .login(authenticationService),
canSignInManually: appSettings.allowOtherAccountProviders, // No need to worry about provisioning links as we hide QR login. canSignInManually: appSettings.allowOtherAccountProviders, // No need to worry about provisioning links as we hide QR login.
orientationManager: appMediator.windowManager, orientationManager: appMediator.windowManager,
appMediator: appMediator)) appMediator: appMediator))
@@ -311,20 +312,24 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.confirmServer(.login)) stateMachine.tryEvent(.confirmServer(.login))
case .cancel: case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) stateMachine.tryEvent(.cancelledLoginWithQR)
case .done(let userSession): case .signedIn(let userSession):
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
// Since the qr code login flow includes verification // Since the qr code login flow includes verification
appSettings.hasRunIdentityConfirmationOnboarding = true appSettings.hasRunIdentityConfirmationOnboarding = true
DispatchQueue.main.async { DispatchQueue.main.async {
self.stateMachine.tryEvent(.signedIn, userInfo: userSession) self.stateMachine.tryEvent(.signedIn, userInfo: userSession)
} }
case .requestOIDCAuthorisation:
fatalError("QR code login shouldn't request an OIDC flow.")
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
navigationStackCoordinator.setSheetCoordinator(coordinator) // Don't use the callback (interactive dismiss disabled), choose the event with the action.
stackCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) // Don't use the callback (interactive dismiss disabled), choose the event with the action.
} }
// MARK: - Manual Authentication // MARK: - Manual Authentication
@@ -50,9 +50,9 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .linkMobileDevice(let progressPublisher): case .linkMobileDevice(let progressPublisher):
break presentQRCodeScreen(mode: .linkMobile(progressPublisher))
case .linkDesktopComputer: case .linkDesktopComputer:
break presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService()))
case .dismiss: case .dismiss:
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
} }
@@ -61,4 +61,27 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
} }
private func presentQRCodeScreen(mode: QRCodeLoginScreenMode) {
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(mode: mode,
canSignInManually: false, // No need to worry about this when linking a device.
orientationManager: flowParameters.appMediator.windowManager,
appMediator: flowParameters.appMediator))
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .signInManually, .signedIn:
fatalError("QR linking shouldn't send sign-in actions.")
case .dismiss:
navigationStackCoordinator.pop()
case .requestOIDCAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url))
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator)
}
} }
@@ -13732,11 +13732,6 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol, @unchecked Sen
} }
} }
class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable { class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
get { return underlyingQrLoginProgressPublisher }
set(value) { underlyingQrLoginProgressPublisher = value }
}
var underlyingQrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never>!
//MARK: - loginWithQRCode //MARK: - loginWithQRCode
@@ -13770,13 +13765,13 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
var loginWithQRCodeDataReceivedData: Data? var loginWithQRCodeDataReceivedData: Data?
var loginWithQRCodeDataReceivedInvocations: [Data] = [] var loginWithQRCodeDataReceivedInvocations: [Data] = []
var loginWithQRCodeDataUnderlyingReturnValue: Result<UserSessionProtocol, AuthenticationServiceError>! var loginWithQRCodeDataUnderlyingReturnValue: QRLoginProgressPublisher!
var loginWithQRCodeDataReturnValue: Result<UserSessionProtocol, AuthenticationServiceError>! { var loginWithQRCodeDataReturnValue: QRLoginProgressPublisher! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return loginWithQRCodeDataUnderlyingReturnValue return loginWithQRCodeDataUnderlyingReturnValue
} else { } else {
var returnValue: Result<UserSessionProtocol, AuthenticationServiceError>? = nil var returnValue: QRLoginProgressPublisher? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = loginWithQRCodeDataUnderlyingReturnValue returnValue = loginWithQRCodeDataUnderlyingReturnValue
} }
@@ -13794,16 +13789,16 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
} }
} }
} }
var loginWithQRCodeDataClosure: ((Data) async -> Result<UserSessionProtocol, AuthenticationServiceError>)? var loginWithQRCodeDataClosure: ((Data) -> QRLoginProgressPublisher)?
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> { func loginWithQRCode(data: Data) -> QRLoginProgressPublisher {
loginWithQRCodeDataCallsCount += 1 loginWithQRCodeDataCallsCount += 1
loginWithQRCodeDataReceivedData = data loginWithQRCodeDataReceivedData = data
DispatchQueue.main.async { DispatchQueue.main.async {
self.loginWithQRCodeDataReceivedInvocations.append(data) self.loginWithQRCodeDataReceivedInvocations.append(data)
} }
if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure { if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure {
return await loginWithQRCodeDataClosure(data) return loginWithQRCodeDataClosure(data)
} else { } else {
return loginWithQRCodeDataReturnValue return loginWithQRCodeDataReturnValue
} }
@@ -7,130 +7,179 @@
// //
// Helpers to remove ECI headers from QR Code raw data // Helpers to remove ECI headers from QR Code raw data
// https://gist.github.com/PetrusM/267e2ee8c1d8b5dca17eac085afa7d7c // Originally based on https://gist.github.com/PetrusM/267e2ee8c1d8b5dca17eac085afa7d7c
import AVKit import AVKit
import Foundation import Foundation
enum AVMetadataBinaryValueError: Error {
case unhandledQRSegmentMode(UInt8)
case bitError(BitError)
case unknown(Error)
}
extension AVMetadataMachineReadableCodeObject { extension AVMetadataMachineReadableCodeObject {
var binaryValue: Data? { var qrBinaryValue: Data? {
switch type { get throws(AVMetadataBinaryValueError) {
case .qr: guard type == .qr else { return nil }
guard let binaryValueWithProtocol,
let symbolVersion = (descriptor as? CIQRCodeDescriptor)?.symbolVersion else { guard let qrCodeDescriptor = descriptor as? CIQRCodeDescriptor else { return nil }
return nil return try Self.removeQRProtocolData(qrCodeDescriptor.errorCorrectedPayload, symbolVersion: qrCodeDescriptor.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? { static func removeQRProtocolData(_ input: Data, symbolVersion: Int) throws(AVMetadataBinaryValueError) -> Data? {
guard let descriptor else { var bits = input.bits()
return nil var segment: [UInt8]
} var output: [UInt8] = []
switch type {
case .qr: repeat {
return (descriptor as? CIQRCodeDescriptor)?.errorCorrectedPayload segment = try takeSegment(&bits, version: symbolVersion)
case .aztec: output.append(contentsOf: segment)
return (descriptor as? CIAztecCodeDescriptor)?.errorCorrectedPayload } while !segment.isEmpty
case .pdf417:
return (descriptor as? CIPDF417CodeDescriptor)?.errorCorrectedPayload return Data(output)
case .dataMatrix:
return (descriptor as? CIDataMatrixCodeDescriptor)?.errorCorrectedPayload
default:
return nil
}
} }
static func removeQrProtocolData(_ input: Data, symbolVersion: Int) -> Data? { private static func takeSegment(_ input: inout [Bit], version: Int) throws(AVMetadataBinaryValueError) -> [UInt8] {
var halves = input.halfBytes() do {
var batch = takeBatch(&halves, version: symbolVersion) let mode = try input.takeBits(4)
var output = batch
while !batch.isEmpty { return switch mode {
batch = takeBatch(&halves, version: symbolVersion) case 0x02: try input.takeAlphanumericSegment(version) // Alphanumeric
output.append(contentsOf: batch) case 0x04: try input.takeBinarySegment(version) // Binary
} case 0x00: [] // End of data
let data = Data(output) default: throw AVMetadataBinaryValueError.unhandledQRSegmentMode(mode)
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 { } catch let error as BitError {
output.append(input.takeUInt8()) throw .bitError(error)
} } catch let error as AVMetadataBinaryValueError {
return output throw error
case 0x00: // End of data } catch {
return [] throw .unknown(error)
default:
return []
} }
} }
} }
private struct HalfByte { enum BitError: Error {
case moreThan8BitsTaken
case moreThan16BitsTaken
case unhandledAlphanumericCharacter(UInt8)
}
private struct Bit {
let value: UInt8 let value: UInt8
} }
private extension [HalfByte] { private extension [Bit] {
mutating func takeUInt8() -> UInt8 { mutating func takeBits(_ count: Int) throws(BitError) -> UInt8 {
let left = remove(at: 0) guard count <= 8 else { throw .moreThan8BitsTaken }
let right = remove(at: 0)
return UInt8(left, right) var value: UInt8 = 0
for _ in 0..<count {
value = (value << 1) | remove(at: 0).value
}
return value
} }
mutating func takeUInt16() -> UInt16 { mutating func takeBits16(_ count: Int) throws(BitError) -> UInt16 {
let first = remove(at: 0) guard count <= 16 else { throw .moreThan16BitsTaken }
let second = remove(at: 0)
let third = remove(at: 0) var value: UInt16 = 0
let fourth = remove(at: 0) for _ in 0..<count {
return UInt16(first, second, third, fourth) value = (value << 1) | UInt16(remove(at: 0).value)
}
return value
}
mutating func takeUInt8() throws(BitError) -> UInt8 {
try takeBits(8)
}
mutating func takeUInt16() throws(BitError) -> UInt16 {
try takeBits16(16)
}
mutating func takeBinarySegment(_ version: Int) throws(BitError) -> [UInt8] {
let characterCountLength = version > 9 ? 16 : 8
let charactersCount = try takeBits16(characterCountLength)
var output = [UInt8]()
for _ in 0..<charactersCount {
try output.append(takeUInt8())
}
return output
}
mutating func takeAlphanumericSegment(_ version: Int) throws(BitError) -> [UInt8] {
let characterCountLength = version > 9 ? (version > 26 ? 13 : 11) : 9
let charactersCount = try takeBits16(characterCountLength)
var output = [UInt8]()
var charactersRemaining = charactersCount
while charactersRemaining > 1 {
if count < 11 {
// done
return output
}
// read the 11 bits
let nextTwoCharacters = try takeBits16(11)
// split into the two characters
try output.append(Self.alphaToByte(UInt8(nextTwoCharacters / 45)))
try output.append(Self.alphaToByte(UInt8(nextTwoCharacters % 45)))
charactersRemaining -= 2
}
if charactersRemaining == 1 {
if count < 6 {
// done
return output
}
let nextCharacter = try takeBits(6)
try output.append(Self.alphaToByte(nextCharacter))
}
return output
}
private static func alphaToByte(_ input: UInt8) throws(BitError) -> UInt8 {
let value: UInt8? = switch input {
case 0...9: input + 0x30 // 0-9
case 10...35: input - 10 + 0x41 // A-Z
case 36: " ".utf8.first
case 37: "$".utf8.first
case 38: "%".utf8.first
case 39: "*".utf8.first
case 40: "+".utf8.first
case 41: "-".utf8.first
case 42: ".".utf8.first
case 43: "/".utf8.first
case 44: ":".utf8.first
default: nil
}
guard let value else { throw .unhandledAlphanumericCharacter(input) }
return value
} }
} }
private extension Data { private extension Data {
func halfBytes() -> [HalfByte] { func bits() -> [Bit] {
var result = [HalfByte]() var result = [Bit]()
forEach { (byte: UInt8) in forEach { (byte: UInt8) in
result.append(contentsOf: byte.halfBytes()) result.append(contentsOf: byte.bits())
} }
return result return result
} }
} }
private extension UInt8 { private extension UInt8 {
func halfBytes() -> [HalfByte] { func bits() -> [Bit] {
[HalfByte(value: self >> 4), HalfByte(value: self & 0x0F)] var bits: [Bit] = []
} for i in 0..<8 {
let bitValue = (self >> (7 - i)) & 1
init(_ left: HalfByte, _ right: HalfByte) { bits.append(Bit(value: bitValue))
self.init((left.value << 4) + (right.value & 0x0F)) }
} return bits
}
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)
} }
} }
@@ -9,7 +9,7 @@ import Combine
import SwiftUI import SwiftUI
enum LinkNewDeviceScreenCoordinatorAction { enum LinkNewDeviceScreenCoordinatorAction {
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher) case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkDesktopComputer case linkDesktopComputer
case dismiss case dismiss
} }
@@ -8,7 +8,7 @@
import Foundation import Foundation
enum LinkNewDeviceScreenViewModelAction { enum LinkNewDeviceScreenViewModelAction {
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher) case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkDesktopComputer case linkDesktopComputer
case dismiss case dismiss
} }
@@ -57,7 +57,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
let linkNewDeviceService = clientProxy.linkNewDeviceService() let linkNewDeviceService = clientProxy.linkNewDeviceService()
let progressPublisher = linkNewDeviceService.generateQRCode() let progressPublisher = linkNewDeviceService.linkMobileDevice()
do { do {
_ = try await progressPublisher.values _ = try await progressPublisher.values
@@ -10,16 +10,17 @@ import Combine
import SwiftUI import SwiftUI
struct QRCodeLoginScreenCoordinatorParameters { struct QRCodeLoginScreenCoordinatorParameters {
let qrCodeLoginService: QRCodeLoginServiceProtocol let mode: QRCodeLoginScreenMode
let canSignInManually: Bool let canSignInManually: Bool
let orientationManager: OrientationManagerProtocol let orientationManager: OrientationManagerProtocol
let appMediator: AppMediatorProtocol let appMediator: AppMediatorProtocol
} }
enum QRCodeLoginScreenCoordinatorAction { enum QRCodeLoginScreenCoordinatorAction {
case cancel case dismiss
case signInManually case signInManually
case done(userSession: UserSessionProtocol) case signedIn(userSession: UserSessionProtocol)
case requestOIDCAuthorisation(URL)
} }
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
@@ -34,7 +35,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: QRCodeLoginScreenCoordinatorParameters) { init(parameters: QRCodeLoginScreenCoordinatorParameters) {
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService, viewModel = QRCodeLoginScreenViewModel(mode: parameters.mode,
canSignInManually: parameters.canSignInManually, canSignInManually: parameters.canSignInManually,
appMediator: parameters.appMediator) appMediator: parameters.appMediator)
orientationManager = parameters.orientationManager orientationManager = parameters.orientationManager
@@ -47,11 +48,13 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .signInManually: case .signInManually:
self.actionsSubject.send(.signInManually) actionsSubject.send(.signInManually)
case .cancel: case .dismiss:
self.actionsSubject.send(.cancel) actionsSubject.send(.dismiss)
case .done(let userSession): case .signedIn(let userSession):
self.actionsSubject.send(.done(userSession: userSession)) actionsSubject.send(.signedIn(userSession: userSession))
case .requestOIDCAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -6,62 +6,89 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import Foundation import SwiftUI
enum QRCodeLoginScreenViewModelAction { enum QRCodeLoginScreenViewModelAction {
case cancel case dismiss
case signInManually case signInManually
case done(userSession: UserSessionProtocol) case signedIn(userSession: UserSessionProtocol)
case requestOIDCAuthorisation(URL)
}
enum QRCodeLoginScreenMode {
/// Configures the screen to login this device by scanning a QR code.
case login(QRCodeLoginServiceProtocol)
/// Configures the screen to link another device by scanning a QR code.
case linkDesktop(LinkNewDeviceService)
/// Configures the screen to link another device by showing it a QR code.
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher)
} }
struct QRCodeLoginScreenViewState: BindableState { struct QRCodeLoginScreenViewState: BindableState {
var state: QRCodeLoginState = .initial var state: QRCodeLoginState
/// Whether or not it is possible for the screen to start the manual sign in flow. This was added to avoid /// Whether or not it is possible for the screen to start the manual sign in flow. This was added to avoid
/// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false. /// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false.
let canSignInManually: Bool let canSignInManually: Bool
let isPresentedModally: Bool
private static let initialStateListItem3AttributedText = { let instructions = QRCodeLoginScreenInstructions()
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder))
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action)
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
let initialStateListItems = [
AttributedString(L10n.screenQrCodeLoginInitialStateItem1(InfoPlistReader.main.productionAppName)),
AttributedString(L10n.screenQrCodeLoginInitialStateItem2),
initialStateListItem3AttributedText,
AttributedString(L10n.screenQrCodeLoginInitialStateItem4)
]
let connectionNotSecureListItems = [
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem1),
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem2),
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem3)
]
var bindings = QRCodeLoginScreenViewStateBindings() var bindings = QRCodeLoginScreenViewStateBindings()
var shouldDisplayCancelButton: Bool {
// TODO: Simplify/validate these assumptions.
if isPresentedModally {
switch state {
case .loginInstructions, .scan, .error(.noCameraPermission): true
default: false
}
} else {
switch state {
case .displayCode, .confirmCode, .scan, .error(.noCameraPermission): true
case .loginInstructions, .linkDesktopInstructions, .displayQR, .error: false
}
}
}
var shouldDisplayBackButton: Bool {
if isPresentedModally {
false
} else {
switch state {
case .loginInstructions, .linkDesktopInstructions, .displayQR: true
case .displayCode, .confirmCode, .scan, .error: false
}
}
}
} }
struct QRCodeLoginScreenViewStateBindings { struct QRCodeLoginScreenViewStateBindings {
var qrResult: Data? var qrResult: Data?
var checkCodeInput = ""
} }
enum QRCodeLoginScreenViewAction { enum QRCodeLoginScreenViewAction {
case cancel case dismiss
case startScan case startScan
case sendCheckCode
case errorAction(QRCodeErrorView.Action) case errorAction(QRCodeErrorView.Action)
} }
enum QRCodeLoginState: Equatable { enum QRCodeLoginState: Equatable {
/// Initial state where the user is informed how to perform the scan /// Initial state where the user is informed how to login this device by scanning a QR code.
case initial case loginInstructions
/// The camera is scanning /// Initial state where the user is informed how to link another device by scanning it's QR code.
case linkDesktopInstructions
/// The camera is scanning a QR code.
case scan(ScanningState) case scan(ScanningState)
/// Codes are being shown /// Codes are being shown.
case displayCode(QRCodeLoginDisplayCodeState) case displayCode(DisplayCodeState)
/// Initial state where the user can link another device using the shown QR code.
case displayQR(UIImage)
/// The user needs to enter the two digit code to confirm the channel is secure
case confirmCode(CheckCodeState)
/// Any full screen error state /// Any full screen error state
case error(ErrorState) case error(ErrorState)
@@ -118,16 +145,30 @@ enum QRCodeLoginState: Equatable {
} }
} }
enum QRCodeLoginDisplayCodeState: Equatable { enum DisplayCodeState: Equatable {
case deviceCode(String) case deviceCode(String)
case verificationCode(String) case verificationCode(String)
var code: String { var code: String {
switch self { switch self {
case .deviceCode(let code): case .deviceCode(let code): code
return code case .verificationCode(let code): code
case .verificationCode(let code): }
return code }
}
enum CheckCodeState: Equatable {
/// The user needs to input the confirmation code.
case inputCode(CheckCodeSenderProxy)
/// The code supplied by the user didn't pass local validation.
case invalidCode
/// The code is being sent.
case sendingCode
var isSending: Bool {
switch self {
case .sendingCode: true
default: false
} }
} }
} }
@@ -145,11 +186,52 @@ enum QRCodeLoginState: Equatable {
default: false default: false
} }
} }
}
var shouldDisplayCancelButton: Bool {
switch self { struct QRCodeLoginScreenInstructions {
case .initial, .scan, .error(.noCameraPermission): true private static let loginItem3 = {
default: false let boldPlaceholder = "{bold}"
} var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder))
} var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action)
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
let loginItems = [
AttributedString(L10n.screenQrCodeLoginInitialStateItem1(InfoPlistReader.main.productionAppName)), // "Open Element on another device"
AttributedString(L10n.screenQrCodeLoginInitialStateItem2), // "Click or tap on your avatar"
loginItem3,
AttributedString(L10n.screenQrCodeLoginInitialStateItem4)
]
private static let linkDesktopItem2 = {
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenLinkNewDeviceMobileStep2(boldPlaceholder))
var boldString = AttributedString(L10n.screenLinkNewDeviceMobileStep2Action)
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
let linkDesktopItems = [
AttributedString(L10n.screenLinkNewDeviceDesktopStep1(InfoPlistReader.main.productionAppName)),
linkDesktopItem2,
AttributedString(L10n.screenLinkNewDeviceDesktopStep3)
]
private static let linkMobile = {
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenLinkNewDeviceMobileStep2(boldPlaceholder))
var boldString = AttributedString(L10n.screenLinkNewDeviceMobileStep2Action)
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
let linkMobileItems = [
AttributedString(L10n.screenLinkNewDeviceMobileStep1(InfoPlistReader.main.productionAppName)),
linkMobile,
AttributedString(L10n.screenLinkNewDeviceMobileStep3)
]
} }
@@ -12,7 +12,7 @@ import Foundation
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction> typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol { class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
private let qrCodeLoginService: QRCodeLoginServiceProtocol private let mode: QRCodeLoginScreenMode
private let appMediator: AppMediatorProtocol private let appMediator: AppMediatorProtocol
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
@@ -20,25 +20,53 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
private var scanTask: Task<Void, Never>? private var currentTask: AnyCancellable?
init(qrCodeLoginService: QRCodeLoginServiceProtocol, init(mode: QRCodeLoginScreenMode,
canSignInManually: Bool, canSignInManually: Bool,
appMediator: AppMediatorProtocol) { appMediator: AppMediatorProtocol) {
self.qrCodeLoginService = qrCodeLoginService self.mode = mode
self.appMediator = appMediator self.appMediator = appMediator
super.init(initialViewState: QRCodeLoginScreenViewState(canSignInManually: canSignInManually))
let initialState: QRCodeLoginScreenViewState = switch mode {
case .login:
.init(state: .loginInstructions, canSignInManually: canSignInManually, isPresentedModally: true)
case .linkDesktop:
.init(state: .linkDesktopInstructions, canSignInManually: canSignInManually, isPresentedModally: false)
case .linkMobile(let progressPublisher):
switch progressPublisher.value {
case .qrReady(let image):
.init(state: .displayQR(image), canSignInManually: canSignInManually, isPresentedModally: false)
default:
.init(state: .error(.unknown), canSignInManually: canSignInManually, isPresentedModally: false)
}
}
super.init(initialViewState: initialState)
setupSubscriptions() setupSubscriptions()
if case .linkMobile(let progressPublisher) = mode {
listenToDisplayQRProgress(progressPublisher: progressPublisher)
}
} }
// MARK: - Public // MARK: - Public
override func process(viewAction: QRCodeLoginScreenViewAction) { override func process(viewAction: QRCodeLoginScreenViewAction) {
switch viewAction { switch viewAction {
case .cancel, .errorAction(.cancel): case .dismiss, .errorAction(.dismiss):
actionsSubject.send(.cancel) actionsSubject.send(.dismiss)
case .startScan, .errorAction(.startScan): case .startScan:
Task { await startScanIfPossible() } Task { await startScanIfPossible() }
case .sendCheckCode:
Task { await sendCheckCode() }
case .errorAction(.startOver):
switch mode {
case .login:
Task { await startScanIfPossible() }
case .linkDesktop, .linkMobile:
actionsSubject.send(.dismiss)
}
case .errorAction(.openSettings): case .errorAction(.openSettings):
appMediator.openAppSettings() appMediator.openAppSettings()
case .errorAction(.signInManually): case .errorAction(.signInManually):
@@ -50,36 +78,22 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
private func setupSubscriptions() { private func setupSubscriptions() {
context.$viewState 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. // 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) .map(\.bindings.qrResult)
.removeDuplicates() .removeDuplicates()
.compactMap { $0 } .compactMap { $0 }
// this needs to be received on the main actor or the state change for connecting won't work properly // this needs to be received on the main actor or the state change for connecting won't work properly
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] qrData in .sink { [weak self] qrData in
self?.handleScan(qrData: qrData) guard let self else { return }
} switch mode {
.store(in: &cancellables) case .login(let qrCodeLoginService):
handleScan(qrData: qrData, loginService: qrCodeLoginService)
qrCodeLoginService.qrLoginProgressPublisher case .linkDesktop(let linkNewDeviceService):
.removeDuplicates() handleScan(qrData: qrData, linkService: linkNewDeviceService)
.receive(on: DispatchQueue.main) case .linkMobile:
.sink { [weak self] progress in fatalError("A code should never be scanned when showing one.")
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) .store(in: &cancellables)
@@ -90,32 +104,160 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission) state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
} }
private func handleScan(qrData: Data) { private func handleScan(qrData: Data, loginService: QRCodeLoginServiceProtocol) {
guard scanTask == nil else { guard currentTask == nil else { return }
return
}
state.state = .scan(.connecting) state.state = .scan(.connecting)
scanTask = Task { [weak self] in MXLog.info("Login scanning QR code")
guard let self else { let progressPublisher = loginService.loginWithQRCode(data: qrData)
return
currentTask = progressPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self else { return }
currentTask = nil
switch completion {
case .finished: break
case .failure(.qrCodeError(let error)):
handleError(error)
case .failure:
handleError(.unknown)
}
} receiveValue: { [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 .starting:
break // Nothing to do, the state was set above.
case .establishingSecureChannel(_, let stringCode):
state.state = .displayCode(.deviceCode(stringCode))
case .waitingForToken(let code):
state.state = .displayCode(.verificationCode(code))
case .syncingSecrets:
break // Nothing to do.
case .signedIn(let session):
MXLog.info("QR Login completed")
actionsSubject.send(.signedIn(userSession: session))
}
} }
}
// TODO: when user cancels in UI then the underlying login needs to be cancelled too. It's unclear if we have that exposed in the bindings yet.
private func handleScan(qrData: Data, linkService: LinkNewDeviceService) {
guard currentTask == nil else { return }
state.state = .scan(.connecting)
MXLog.info("Link scanning QR code")
let progressPublisher = linkService.linkDesktopDevice(with: qrData)
currentTask = progressPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
guard let self else { return }
currentTask = nil
if case .failure(let error) = completion {
handleError(error)
}
} receiveValue: { [weak self] progress in
MXLog.info("Linking with QR 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 .starting:
break // Nothing to do, the state was set above.
case .establishingSecureChannel(let checkCodeString):
state.state = .displayCode(.deviceCode(checkCodeString))
case .waitingForAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url))
case .syncingSecrets:
break // Nothing to do.
case .done:
MXLog.info("Link with QR code completed.")
actionsSubject.send(.dismiss)
}
}
}
private func listenToDisplayQRProgress(progressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher) {
state.bindings.qrResult = nil
defer { MXLog.info("Link showing QR code.")
scanTask = nil
} currentTask = progressPublisher
.removeDuplicates()
MXLog.info("Scanning QR code: \(qrData)") .receive(on: DispatchQueue.main)
switch await qrCodeLoginService.loginWithQRCode(data: qrData) { .sink { [weak self] completion in
case let .success(session): guard let self else { return }
MXLog.info("QR Login completed") currentTask = nil
actionsSubject.send(.done(userSession: session))
case .failure(.qrCodeError(let error)): if case .failure(let error) = completion {
handleError(error) handleError(error)
case .failure: }
handleError(.unknown) } receiveValue: { [weak self] progress in
MXLog.info("Linking with QR 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 .starting, .qrReady:
break // Nothing to do, we are already showing the code by the time this method is called.
case .qrScanned(let checkCodeSender):
state.state = .confirmCode(.inputCode(checkCodeSender))
case .waitingForAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url))
case .syncingSecrets:
break // Nothing to do.
case .done:
MXLog.info("Link with QR code completed.")
actionsSubject.send(.dismiss)
}
} }
}
private func sendCheckCode() async {
guard case let .confirmCode(.inputCode(checkCodeSender)) = state.state else {
fatalError("Attempting to check code from the wrong state.")
}
let stringValue = state.bindings.checkCodeInput
let code = UInt8(stringValue) ?? 0
if !checkCodeSender.validate(checkCode: code) {
MXLog.error("Invalid code entered.")
state.state = .confirmCode(.invalidCode)
return
}
state.state = .confirmCode(.sendingCode)
do {
MXLog.info("Valid code entered, sending.")
try await checkCodeSender.send(code: code)
} catch {
MXLog.error("Failed to send check code: \(error)")
handleError(.unknown)
} }
} }
@@ -128,8 +270,6 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
state.state = .scan(.scanFailed(.notAllowed(scannedProvider: scannedProvider, allowedProviders: allowedProviders))) state.state = .scan(.scanFailed(.notAllowed(scannedProvider: scannedProvider, allowedProviders: allowedProviders)))
case .deviceNotSignedIn: case .deviceNotSignedIn:
state.state = .scan(.scanFailed(.deviceNotSignedIn)) state.state = .scan(.scanFailed(.deviceNotSignedIn))
case .deviceAlreadySignedIn:
state.state = .error(.deviceAlreadySignedIn)
case .cancelled: case .cancelled:
state.state = .error(.cancelled) state.state = .error(.cancelled)
case .connectionInsecure: case .connectionInsecure:
@@ -142,21 +282,32 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
state.state = .error(.expired) state.state = .error(.expired)
case .deviceNotSupported: case .deviceNotSupported:
state.state = .error(.deviceNotSupported) state.state = .error(.deviceNotSupported)
case .deviceAlreadySignedIn:
state.state = .error(.deviceAlreadySignedIn)
case .unknown: case .unknown:
state.state = .error(.unknown) state.state = .error(.unknown)
} }
} }
/// Only for mocking initial states /// Only for mocking initial states
fileprivate init(state: QRCodeLoginState, canSignInManually: Bool) { fileprivate init(state: QRCodeLoginState, canSignInManually: Bool, isPresentedModally: Bool, checkCodeInput: String) {
qrCodeLoginService = QRCodeLoginServiceMock() mode = .login(QRCodeLoginServiceMock())
appMediator = AppMediatorMock.default appMediator = AppMediatorMock.default
super.init(initialViewState: .init(state: state, canSignInManually: canSignInManually)) super.init(initialViewState: .init(state: state,
canSignInManually: canSignInManually,
isPresentedModally: isPresentedModally,
bindings: .init(checkCodeInput: checkCodeInput)))
} }
} }
extension QRCodeLoginScreenViewModel { extension QRCodeLoginScreenViewModel {
static func mock(state: QRCodeLoginState, canSignInManually: Bool = true) -> QRCodeLoginScreenViewModel { static func mock(state: QRCodeLoginState,
QRCodeLoginScreenViewModel(state: state, canSignInManually: canSignInManually) canSignInManually: Bool = true,
isPresentedModally: Bool = true,
checkCodeInput: String = "") -> QRCodeLoginScreenViewModel {
QRCodeLoginScreenViewModel(state: state,
canSignInManually: canSignInManually,
isPresentedModally: isPresentedModally,
checkCodeInput: checkCodeInput)
} }
} }
@@ -12,7 +12,7 @@ struct QRCodeErrorView: View {
let errorState: QRCodeLoginState.ErrorState let errorState: QRCodeLoginState.ErrorState
let canSignInManually: Bool let canSignInManually: Bool
enum Action { case openSettings, startScan, signInManually, cancel } enum Action { case openSettings, startOver, signInManually, dismiss }
let action: (Action) -> Void let action: (Action) -> Void
var title: String { var title: String {
@@ -127,12 +127,12 @@ struct QRCodeErrorView: View {
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported: case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported:
Button(L10n.screenQrCodeLoginStartOverButton) { Button(L10n.screenQrCodeLoginStartOverButton) {
action(.startScan) action(.startOver)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
case .cancelled: case .cancelled:
Button(L10n.actionTryAgain) { Button(L10n.actionTryAgain) {
action(.startScan) action(.startOver)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
case .linkingNotSupported: case .linkingNotSupported:
@@ -145,13 +145,13 @@ struct QRCodeErrorView: View {
} }
Button(L10n.actionCancel) { Button(L10n.actionCancel) {
action(.cancel) action(.dismiss)
} }
.buttonStyle(.compound(.tertiary)) .buttonStyle(.compound(.tertiary))
} }
case .deviceAlreadySignedIn: case .deviceAlreadySignedIn:
Button(L10n.actionContinue) { Button(L10n.actionContinue) {
action(.cancel) action(.dismiss)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
} }
@@ -11,7 +11,9 @@ import SwiftUI
struct QRCodeLoginScreen: View { struct QRCodeLoginScreen: View {
@ObservedObject var context: QRCodeLoginScreenViewModel.Context @ObservedObject var context: QRCodeLoginScreenViewModel.Context
@State private var qrFrame = CGRect.zero @State private var qrFrame = CGRect.zero
@FocusState private var checkCodeInputFocus
var backgroundStyle: Color { var backgroundStyle: Color {
if case .error = context.viewState.state { if case .error = context.viewState.state {
@@ -22,25 +24,30 @@ struct QRCodeLoginScreen: View {
} }
var body: some View { var body: some View {
NavigationStack { mainContent
mainContent .toolbar { toolbar }
.toolbar { toolbar } .toolbar(.visible, for: .navigationBar)
.toolbar(.visible, for: .navigationBar) .background()
.background() .backgroundStyle(backgroundStyle)
.backgroundStyle(backgroundStyle) .interactiveDismissDisabled()
.interactiveDismissDisabled() .navigationBarBackButtonHidden(!context.viewState.shouldDisplayBackButton)
}
} }
@ViewBuilder @ViewBuilder
var mainContent: some View { var mainContent: some View {
switch context.viewState.state { switch context.viewState.state {
case .initial: case .loginInstructions:
initialContent loginInstructionsContent
case .linkDesktopInstructions:
linkDesktopInstructionsContent
case .scan: case .scan:
qrScanContent qrScannerContent
case .displayCode: case .displayCode:
displayCodeContent displayCodeContent
case .displayQR:
displayQRContent
case .confirmCode:
confirmCodeContent
case .error(let errorState): case .error(let errorState):
QRCodeErrorView(errorState: errorState, canSignInManually: context.viewState.canSignInManually) { action in QRCodeErrorView(errorState: errorState, canSignInManually: context.viewState.canSignInManually) { action in
context.send(viewAction: .errorAction(action)) context.send(viewAction: .errorAction(action))
@@ -48,7 +55,7 @@ struct QRCodeLoginScreen: View {
} }
} }
private var initialContent: some View { private var loginInstructionsContent: some View {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) { FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
VStack(alignment: .leading, spacing: 40) { VStack(alignment: .leading, spacing: 40) {
TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName), TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName),
@@ -56,7 +63,7 @@ struct QRCodeLoginScreen: View {
icon: \.computer, icon: \.computer,
iconStyle: .default) iconStyle: .default)
SFNumberedListView(items: context.viewState.initialStateListItems) SFNumberedListView(items: context.viewState.instructions.loginItems)
} }
} bottomContent: { } bottomContent: {
Button(L10n.screenQrCodeLoginInitialStateButtonTitle) { Button(L10n.screenQrCodeLoginInitialStateButtonTitle) {
@@ -66,6 +73,23 @@ struct QRCodeLoginScreen: View {
} }
} }
private var linkDesktopInstructionsContent: some View {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
VStack(alignment: .leading, spacing: 40) {
TitleAndIcon(title: L10n.screenLinkNewDeviceDesktopTitle(InfoPlistReader.main.productionAppName),
icon: \.computer,
iconStyle: .default)
SFNumberedListView(items: context.viewState.instructions.linkDesktopItems)
}
} bottomContent: {
Button(L10n.screenLinkNewDeviceDesktopSubmit) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
}
}
@ViewBuilder @ViewBuilder
private var displayCodeContent: some View { private var displayCodeContent: some View {
if case let .displayCode(displayCodeState) = context.viewState.state { if case let .displayCode(displayCodeState) = context.viewState.state {
@@ -92,14 +116,14 @@ struct QRCodeLoginScreen: View {
} }
} bottomContent: { } bottomContent: {
Button(L10n.actionCancel) { Button(L10n.actionCancel) {
context.send(viewAction: .cancel) context.send(viewAction: .dismiss)
} }
.buttonStyle(.compound(.secondary)) .buttonStyle(.compound(.secondary))
} }
} }
} }
private func displayCodeHeader(state: QRCodeLoginState.QRCodeLoginDisplayCodeState) -> some View { private func displayCodeHeader(state: QRCodeLoginState.DisplayCodeState) -> some View {
switch state { switch state {
case .deviceCode: case .deviceCode:
TitleAndIcon(title: L10n.screenQrCodeLoginDeviceCodeTitle, TitleAndIcon(title: L10n.screenQrCodeLoginDeviceCodeTitle,
@@ -114,7 +138,7 @@ struct QRCodeLoginScreen: View {
} }
} }
private var qrScanContent: some View { private var qrScannerContent: some View {
FullscreenDialog(topPadding: 24) { FullscreenDialog(topPadding: 24) {
VStack(spacing: 40) { VStack(spacing: 40) {
TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle, TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle,
@@ -124,12 +148,12 @@ struct QRCodeLoginScreen: View {
qrScanner qrScanner
} }
} bottomContent: { } bottomContent: {
qrScanFooter qrScannerFooter
} }
} }
@ViewBuilder @ViewBuilder
private var qrScanFooter: some View { private var qrScannerFooter: some View {
if case let .scan(scanState) = context.viewState.state { if case let .scan(scanState) = context.viewState.state {
switch scanState { switch scanState {
case .connecting: case .connecting:
@@ -183,12 +207,82 @@ struct QRCodeLoginScreen: View {
) )
} }
@ViewBuilder
private var displayQRContent: some View {
if case let .displayQR(image) = context.viewState.state {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
VStack(spacing: 32) {
TitleAndIcon(title: L10n.screenLinkNewDeviceMobileTitle(InfoPlistReader.main.productionAppName),
icon: \.takePhotoSolid,
iconStyle: .default)
Image(uiImage: image)
.interpolation(.none) // to stop it getting blurred
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
SFNumberedListView(items: context.viewState.instructions.linkMobileItems)
}
} bottomContent: { }
}
}
@ViewBuilder
private var confirmCodeContent: some View {
if case let .confirmCode(confirmCode) = context.viewState.state {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
VStack(spacing: 24) {
TitleAndIcon(title: L10n.screenLinkNewDeviceEnterNumberTitle,
subtitle: L10n.screenLinkNewDeviceEnterNumberSubtitle,
icon: \.computer,
iconStyle: .default)
VStack(spacing: 10) {
Text(L10n.screenLinkNewDeviceEnterNumberNotice)
.font(.compound.bodyMDSemibold)
.multilineTextAlignment(.center)
.foregroundStyle(.compound.textSecondary)
PINTextField(pinCode: $context.checkCodeInput, maxLength: 2, size: .medium)
.focused($checkCodeInputFocus)
.disabled(confirmCode.isSending)
if case .confirmCode(.invalidCode) = context.viewState.state {
Label(L10n.screenLinkNewDeviceEnterNumberErrorNumbersDoNotMatch,
icon: \.errorSolid,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
}
}
}
} bottomContent: {
if case .inputCode = confirmCode {
Button(L10n.actionContinue) {
context.send(viewAction: .sendCheckCode)
}
.buttonStyle(.compound(.primary))
.disabled(context.checkCodeInput.count < 2 || confirmCode.isSending)
} else {
Button(L10n.actionStartOver) {
context.send(viewAction: .errorAction(.startOver))
}
.buttonStyle(.compound(.primary))
}
}
.onAppear { checkCodeInputFocus = true }
}
}
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbar: some ToolbarContent { private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
if context.viewState.state.shouldDisplayCancelButton { if context.viewState.shouldDisplayCancelButton {
Button(L10n.actionCancel) { Button(L10n.actionCancel) {
context.send(viewAction: .cancel) context.send(viewAction: .dismiss)
} }
} }
} }
@@ -216,15 +310,16 @@ private struct QRScannerViewOverlay: View {
var body: some View { var body: some View {
Rectangle() Rectangle()
.stroke(.compound.textPrimary, style: StrokeStyle(lineWidth: 4.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase)) .stroke(.compound.textPrimary, style: StrokeStyle(lineWidth: 6.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase))
} }
} }
// MARK: - Previews // MARK: - Previews
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
// Initial // Instructions
static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial) static let loginInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .loginInstructions)
static let linkInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .linkDesktopInstructions)
// Scanning // Scanning
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning)) static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
@@ -239,40 +334,57 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn))) static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
// Display Code // Showing
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12"))) static let showingStateViewModel = {
let base64QRCode = GrantLoginWithQrCodeHandlerSDKMock.Configuration().generatedBase64QRCode
let image = base64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage()
return QRCodeLoginScreenViewModel.mock(state: .displayQR(image))
}()
// Displaying codes
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456"))) static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
static let confirmCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.inputCode(CheckCodeSenderProxy(underlyingSender: CheckCodeSenderSDKMock()))))
static let confirmCodeEnteredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.inputCode(CheckCodeSenderProxy(underlyingSender: CheckCodeSenderSDKMock()))), checkCodeInput: "12")
static let confirmCodeInvalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.invalidCode))
// Errors (no need to test them all QRCodeErrorView covers that). // Errors (no need to test them all QRCodeErrorView covers that).
static let errorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined)) static let errorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined))
static var previews: some View { static var previews: some View {
QRCodeLoginScreen(context: initialStateViewModel.context) NavigationStack { QRCodeLoginScreen(context: loginInstructionsStateViewModel.context) }
.previewDisplayName("Initial") .previewDisplayName("Login instructions")
NavigationStack { QRCodeLoginScreen(context: linkInstructionsStateViewModel.context) }
.previewDisplayName("Link instructions")
QRCodeLoginScreen(context: scanningStateViewModel.context) NavigationStack { QRCodeLoginScreen(context: scanningStateViewModel.context) }
.previewDisplayName("Scanning") .previewDisplayName("Scanning")
NavigationStack { QRCodeLoginScreen(context: connectingStateViewModel.context) }
QRCodeLoginScreen(context: connectingStateViewModel.context)
.previewDisplayName("Connecting") .previewDisplayName("Connecting")
NavigationStack { QRCodeLoginScreen(context: invalidStateViewModel.context) }
QRCodeLoginScreen(context: invalidStateViewModel.context)
.previewDisplayName("Invalid") .previewDisplayName("Invalid")
NavigationStack { QRCodeLoginScreen(context: notAllowedStateViewModel.context) }
QRCodeLoginScreen(context: notAllowedStateViewModel.context)
.previewDisplayName("Not allowed") .previewDisplayName("Not allowed")
NavigationStack { QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context) }
QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context)
.previewDisplayName("Device not signed in") .previewDisplayName("Device not signed in")
QRCodeLoginScreen(context: deviceCodeStateViewModel.context) NavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) }
.previewDisplayName("Device code") .previewDisplayName("Showing")
QRCodeLoginScreen(context: verificationCodeStateViewModel.context) NavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) }
.previewDisplayName("Device code")
NavigationStack { QRCodeLoginScreen(context: verificationCodeStateViewModel.context) }
.previewDisplayName("Verification code") .previewDisplayName("Verification code")
QRCodeLoginScreen(context: errorStateViewModel.context) NavigationStack { QRCodeLoginScreen(context: confirmCodeStateViewModel.context) }
.previewDisplayName("Confirm code")
NavigationStack { QRCodeLoginScreen(context: confirmCodeEnteredStateViewModel.context) }
.previewDisplayName("Confirm code entered")
NavigationStack { QRCodeLoginScreen(context: confirmCodeInvalidStateViewModel.context) }
.previewDisplayName("Confirm code invalid")
NavigationStack { QRCodeLoginScreen(context: errorStateViewModel.context) }
.previewDisplayName("Error") .previewDisplayName("Error")
} }
} }
@@ -41,16 +41,18 @@ struct QRCodeScannerView: UIViewControllerRepresentable {
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { 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. // Check if the metadataObjects array is not nil and it contains at least one object.
guard metadataObjects.count > 0, guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject else {
let metadataObj = metadataObjects[0] as? AVMetadataMachineReadableCodeObject, MXLog.error("Invalid QR scan")
metadataObj.type == AVMetadataObject.ObjectType.qr,
let data = metadataObj.binaryValue else {
MXLog.info("QRCodeScannerView: invalid qr scan")
return return
} }
scanResult = data do {
MXLog.info("QRCodeScannerView: scanned data") let data = try metadataObject.qrBinaryValue
scanResult = data
MXLog.info("Scanned data")
} catch {
MXLog.error("Invalid QR code: \(error)")
}
} }
} }
} }
@@ -24,11 +24,6 @@ class AuthenticationService: AuthenticationServiceProtocol {
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() } var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
private(set) var flow: AuthenticationFlow private(set) var flow: AuthenticationFlow
private let qrLoginProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
qrLoginProgressSubject.eraseToAnyPublisher()
}
init(userSessionStore: UserSessionStoreProtocol, init(userSessionStore: UserSessionStoreProtocol,
encryptionKeyProvider: EncryptionKeyProviderProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol,
clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(), clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(),
@@ -153,43 +148,59 @@ class AuthenticationService: AuthenticationServiceProtocol {
} }
} }
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> { func loginWithQRCode(data: Data) -> QRLoginProgressPublisher {
let progressSubject = CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>(.starting)
let qrData: QrCodeData let qrData: QrCodeData
do { do {
qrData = try QrCodeData.fromBytes(bytes: data) qrData = try QrCodeData.fromBytes(bytes: data)
} catch { } catch {
MXLog.error("QRCode decode error: \(error)") MXLog.error("QRCode decode error: \(error)")
return .failure(.qrCodeError(.invalidQRCode)) progressSubject.send(completion: .failure(.qrCodeError(.invalidQRCode)))
return progressSubject.asCurrentValuePublisher()
} }
guard let scannedServerName = qrData.serverName() else { guard let scannedServerName = qrData.serverName() else {
MXLog.error("The QR code is from a device that is not yet signed in.") MXLog.error("The QR code is from a device that is not yet signed in.")
return .failure(.qrCodeError(.deviceNotSignedIn)) progressSubject.send(completion: .failure(.qrCodeError(.deviceNotSignedIn)))
return progressSubject.asCurrentValuePublisher()
} }
if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) { if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) {
MXLog.error("The scanned device's server is not allowed: \(scannedServerName)") MXLog.error("The scanned device's server is not allowed: \(scannedServerName)")
return .failure(.qrCodeError(.providerNotAllowed(scannedProvider: scannedServerName, allowedProviders: appSettings.accountProviders))) progressSubject.send(completion: .failure(.qrCodeError(.providerNotAllowed(scannedProvider: scannedServerName, allowedProviders: appSettings.accountProviders))))
return progressSubject.asCurrentValuePublisher()
} }
let listener = SDKListener { [weak self] progress in let listener = SDKListener { progress in
self?.qrLoginProgressSubject.send(progress) guard let progress = QRLoginProgress(rustProgress: progress) else { return }
progressSubject.send(progress)
} }
do { Task {
let client = try await makeClient(homeserverAddress: scannedServerName) do {
let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue) let client = try await makeClient(homeserverAddress: scannedServerName)
try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener) let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue)
return await userSession(for: client) try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener)
} catch let error as HumanQrLoginError {
MXLog.error("QRCode login error: \(error)") switch await userSession(for: client) {
return .failure(error.serviceError) case .success(let userSession):
} catch RemoteSettingsError.elementProRequired(let serverName) { progressSubject.send(.signedIn(userSession))
return .failure(.elementProRequired(serverName: serverName)) case .failure(let error):
} catch { progressSubject.send(completion: .failure(error))
MXLog.error("QRCode login unknown error: \(error)") }
return .failure(.qrCodeError(.unknown)) } catch let error as HumanQrLoginError {
MXLog.error("QRCode login error: \(error)")
progressSubject.send(completion: .failure(error.serviceError))
} catch RemoteSettingsError.elementProRequired(let serverName) {
progressSubject.send(completion: .failure(.elementProRequired(serverName: serverName)))
} catch {
MXLog.error("QRCode login unknown error: \(error)")
progressSubject.send(completion: .failure(.qrCodeError(.unknown)))
}
} }
return progressSubject.asCurrentValuePublisher()
} }
func reset() { func reset() {
@@ -111,7 +111,58 @@ enum QRCodeLoginError: Error, Equatable {
// sourcery: AutoMockable // sourcery: AutoMockable
protocol QRCodeLoginServiceProtocol { protocol QRCodeLoginServiceProtocol {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> { get } typealias QRLoginProgressPublisher = CurrentValuePublisher<QRLoginProgress, AuthenticationServiceError>
func loginWithQRCode(data: Data) -> QRLoginProgressPublisher
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> }
enum QRLoginProgress {
case starting
case establishingSecureChannel(checkCode: UInt8, checkCodeString: String)
case waitingForToken(userCode: String)
case syncingSecrets
case signedIn(UserSessionProtocol)
init?(rustProgress: QrLoginProgress) {
switch rustProgress {
case .starting:
self = .starting
case .establishingSecureChannel(let checkCode, let checkCodeString):
self = .establishingSecureChannel(checkCode: checkCode, checkCodeString: checkCodeString)
case .waitingForToken(let userCode):
self = .waitingForToken(userCode: userCode)
case .syncingSecrets:
self = .syncingSecrets
case .done:
return nil // The SDK is done, but the app still needs to set up the UserSession.
}
}
}
extension QRLoginProgress: Equatable, CustomStringConvertible {
static func == (lhs: QRLoginProgress, rhs: QRLoginProgress) -> Bool {
switch (lhs, rhs) {
case (.starting, .starting):
true
case let (.establishingSecureChannel(lhsCheckCode, lhsCheckCodeString), .establishingSecureChannel(rhsCheckCode, rhsCheckCodeString)):
lhsCheckCode == rhsCheckCode && lhsCheckCodeString == rhsCheckCodeString
case let (.waitingForToken(lhsUserCode), .waitingForToken(rhsUserCode)):
lhsUserCode == rhsUserCode
case (.syncingSecrets, .syncingSecrets):
true
case (.signedIn, .signedIn):
true
default:
false
}
}
var description: String {
switch self {
case .starting: "starting"
case .establishingSecureChannel: "establishingSecureChannel"
case .waitingForToken: "waitingForToken"
case .syncingSecrets: "syncingSecrets"
case .signedIn: "signedIn"
}
}
} }
@@ -11,21 +11,25 @@ import MatrixRustSDK
import SwiftUI import SwiftUI
class LinkNewDeviceService { class LinkNewDeviceService {
typealias GenerateProgressPublisher = CurrentValuePublisher<GenerateProgress, QRCodeLoginError> /// Publishes the progress of linking a new device by showing it a QR code.
typealias ScanProgressPublisher = CurrentValuePublisher<ScanProgress, QRCodeLoginError> typealias LinkMobileProgressPublisher = CurrentValuePublisher<LinkMobileProgress, QRCodeLoginError>
/// Publishes the progress of linking a new device by scanning a QR code generated by said device.
typealias LinkDesktopProgressPublisher = CurrentValuePublisher<LinkDesktopProgress, QRCodeLoginError>
enum GenerateProgress { /// The progress of linking a new device by showing it a QR code.
enum LinkMobileProgress: Equatable {
case starting case starting
case qrReady(UIImage) case qrReady(UIImage)
case qrScanned(CheckCodeSenderProtocol) case qrScanned(CheckCodeSenderProxy)
case waitingForAuthorisation(verificationURL: URL) case waitingForAuthorisation(verificationURL: URL)
case syncingSecrets case syncingSecrets
case done case done
} }
enum ScanProgress { /// The progress of linking a new device by scanning a QR code generated by said device.
enum LinkDesktopProgress: Equatable {
case starting case starting
case establishingSecureChannel(checkCode: UInt8, checkCodeString: String) case establishingSecureChannel(checkCodeString: String)
case waitingForAuthorisation(verificationURL: URL) case waitingForAuthorisation(verificationURL: URL)
case syncingSecrets case syncingSecrets
case done case done
@@ -37,8 +41,9 @@ class LinkNewDeviceService {
grantLoginHandler = handler grantLoginHandler = handler
} }
func generateQRCode() -> GenerateProgressPublisher { /// Links a new device by showing it a QR code.
let progressSubject = CurrentValueSubject<GenerateProgress, QRCodeLoginError>(.starting) func linkMobileDevice() -> LinkMobileProgressPublisher {
let progressSubject = CurrentValueSubject<LinkMobileProgress, QRCodeLoginError>(.starting)
let listener = SDKListener { let listener = SDKListener {
do { do {
try progressSubject.send(.init(rustProgress: $0)) try progressSubject.send(.init(rustProgress: $0))
@@ -64,8 +69,9 @@ class LinkNewDeviceService {
return progressSubject.asCurrentValuePublisher() return progressSubject.asCurrentValuePublisher()
} }
func scanQRCode(_ scannedQRData: Data) -> ScanProgressPublisher { /// Links a new device using a QR code generated by said device.
let progressSubject = CurrentValueSubject<ScanProgress, QRCodeLoginError>(.starting) func linkDesktopDevice(with scannedQRData: Data) -> LinkDesktopProgressPublisher {
let progressSubject = CurrentValueSubject<LinkDesktopProgress, QRCodeLoginError>(.starting)
let listener = SDKListener { let listener = SDKListener {
do { do {
try progressSubject.send(.init(rustProgress: $0)) try progressSubject.send(.init(rustProgress: $0))
@@ -84,7 +90,8 @@ class LinkNewDeviceService {
return progressSubject.asCurrentValuePublisher() return progressSubject.asCurrentValuePublisher()
} }
#warning("Check intent/server name here…") #warning("Check intent/server name??")
#warning("Check Element Pro here??")
Task { Task {
do { do {
@@ -103,7 +110,7 @@ class LinkNewDeviceService {
} }
} }
extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible { extension LinkNewDeviceService.LinkMobileProgress: CustomStringConvertible {
enum Error: Swift.Error { enum Error: Swift.Error {
case invalidQRCodeData case invalidQRCodeData
case invalidVerificationURI(String) case invalidVerificationURI(String)
@@ -118,7 +125,7 @@ extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible {
} else { } else {
throw Error.invalidQRCodeData throw Error.invalidQRCodeData
} }
case .qrScanned(let checkCodeSender): .qrScanned(checkCodeSender) case .qrScanned(let checkCodeSender): .qrScanned(.init(underlyingSender: checkCodeSender))
case .waitingForAuth(let verificationURI): case .waitingForAuth(let verificationURI):
// verificationURI is a String; ASWebAuthenticationSession requires a URL. // verificationURI is a String; ASWebAuthenticationSession requires a URL.
if let url = URL(string: verificationURI) { if let url = URL(string: verificationURI) {
@@ -143,13 +150,13 @@ extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible {
} }
} }
extension LinkNewDeviceService.ScanProgress: CustomStringConvertible { extension LinkNewDeviceService.LinkDesktopProgress: CustomStringConvertible {
enum Error: Swift.Error { case invalidVerificationURI(String) } enum Error: Swift.Error { case invalidVerificationURI(String) }
init(rustProgress: GrantQrLoginProgress) throws { init(rustProgress: GrantQrLoginProgress) throws {
self = switch rustProgress { self = switch rustProgress {
case .starting: .starting case .starting: .starting
case .establishingSecureChannel(let checkCode, let checkCodeString): .establishingSecureChannel(checkCode: checkCode, checkCodeString: checkCodeString) case .establishingSecureChannel(_, let checkCodeString): .establishingSecureChannel(checkCodeString: checkCodeString)
case .waitingForAuth(let verificationURI): case .waitingForAuth(let verificationURI):
// verificationURI is a String; ASWebAuthenticationSession requires a URL. // verificationURI is a String; ASWebAuthenticationSession requires a URL.
if let url = URL(string: verificationURI) { if let url = URL(string: verificationURI) {
@@ -186,7 +193,26 @@ private extension QRCodeLoginError {
} }
} }
private extension UIImage { class CheckCodeSenderProxy: Equatable {
static func == (lhs: CheckCodeSenderProxy, rhs: CheckCodeSenderProxy) -> Bool {
lhs.underlyingSender === rhs.underlyingSender
}
let underlyingSender: CheckCodeSenderProtocol
init(underlyingSender: CheckCodeSenderProtocol) {
self.underlyingSender = underlyingSender
}
func send(code: UInt8) async throws {
try await underlyingSender.send(code: code)
}
#warning("Waiting for an SDK update to use the underlying sender.")
func validate(checkCode: UInt8) -> Bool { true }
}
extension UIImage {
convenience init?(qrCodeData: Data) { convenience init?(qrCodeData: Data) {
let qrContext = CIContext() let qrContext = CIContext()
let qrFilter = CIFilter.qrCodeGenerator() let qrFilter = CIFilter.qrCodeGenerator()
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a63a91ecf6e3637ec861f1100ebc97fd66abd3faeb014ae7812060f4400b82dc
size 112052
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5143e2e544a6aca9373fa9680e2c3a7fbb28be55a77d9f283b0656d5f0340286
size 132923
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b12167a9d5d01b0f1c40ea742d1e3a6b0b5bfce9b712ab78367abbb0e91294e
size 65280
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4748af8b7a64f4c49253508662a7754727530c91a0fa62ec9d6006d13e84048
size 86517
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:17624efed44c446b37934f930dc1be97a6eabff196556707edc5b29d757bed3f
size 112327
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7581d78168eee58b20477e28e45f129bfce099e8671d19f7ed2bb3ba5cf07ce
size 133584
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73423e5a5d8d101f9fdb5204d9f89a3d5881fe2897b8e6c676c2b5cf8ad819bd
size 65658
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3efadbacd57c47f8de49a5ffc6437e4bbad98b35455ddb4004238edfc31fa225
size 86991
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75d2836225b86cc489ffb2e97d2b88a5a615497013c52a754e11d6064291efa7
size 120243
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12e674f700020c1b8ec14d1dd281805bb551ffe2cf510fa9966fbd7bd99e02da
size 146278
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:52f93b01f90d1287ec889a2416eb0881875276343a9f7cd42cc6041e4410574d
size 71887
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b6b686d6b4f544c143d4dfd71b0c66417de9b7e7bc0f99f5ef66f8bacaff2e9
size 97854
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f2967fe12faab759a15dedd5285ecf29c072fea9de3558834a40481d2afd659 oid sha256:2fbe2e5f121dfe92ae934f15e550f66fc752848d88a7cc403c26e7233b9300c6
size 105436 size 105657
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b741a5591c5b0ea9371f00f177383aa04e3d23fa44341fdd30a8fb013fa9dac2 oid sha256:3f580ac10e8da845e63f237cef93acdbca26ca480c6595a05d1098a1cb07da34
size 109419 size 109350
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:131d0c3f463b81f0413c0c517c8b8f5a9cba4c786c77f9dae15cd62c39cc070f oid sha256:f1efeedc569846e8d896de85c20da4876b278671ffcd1a30ae72f8ad10bc1314
size 46233 size 46576
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:47ebd411e4cc3a5100e72a0799c0b5f331025073eceaf35ace4063b06dda6e5c oid sha256:a8e19b9a1790a26f7bd76f11a2b5425d3545584613f3e39eca3f109b65722ea4
size 53670 size 56081
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6a747e378979ffc936d8d3481ab31a5f5839a1544e736ccfe300aa56f42ffbf2 oid sha256:9e3c13b00e7e452c71175cc262983cbf9a500acbcdd4f525d335759e0a0fda9a
size 122713 size 122905
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3afd52dd8852df06eaa75d39d5a6c3542caacb852e174cc092d22af7665f554f oid sha256:6b59ab75c3a03ade9121fc4fc0b24f87732d2ea222a99922bf39a57f9bb0175b
size 138264 size 138177
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d3da51241718c13a77b60150526f719ea95f3e93bf216cc48ebce119b71aea4b oid sha256:3a2995fbb7b5339fcd062a3f811698697dcdbb264672aabb965181e5fa756529
size 64014 size 64409
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3262cbaa505948ad223c1c91e2cbed5c495fa3598a8a5aa1b199988edf17d8f5 oid sha256:9e94e59a5157ccc95dff6e528f826c1920d7ee95bd51738e26643ff815f53113
size 81960 size 84372
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:16254261843f052920c95d965b3016819e0d865412d5ba6de5f591a71d2c8fc3 oid sha256:85fb5855adedb446af1fc92f730a6fe2b4a53f48c2ec8c8028ef36d82e9cb6c3
size 117347 size 117537
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:312f8605592eef5075c3da92d2f075b939da391ac1a41fdb0f025d1bc45478de oid sha256:641136b99c2c39ab0ad2f842e43b5b589e15c8ac14c1bd10110535eb648921b4
size 122574 size 122486
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:af249b371b990aa1d04a05ae9d9b008e7c5557281eaf22a9275902f909e9af7d oid sha256:4e5b1f5d79e1838bf4b6fee3cbec9e672a5446544ebd1e2c5c5f16126c896e12
size 56989 size 57386
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f6fbce453be9a4de2ad06c5b09b549e9cc2f8295082f69245f555838ee290f50 oid sha256:809a8b95dc396882887e2eae8c3df421adc962955e6a41cfa893b595a198ddbc
size 69272 size 71703
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19cfafc5a77aec22b3000647fdb3037a97ede0c372ea92ff8b7c13b96d9f522b
size 128392
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63f38ed565d61eae5efeec35e6855393862e60ed0c40a0033fc49cc1a289f1e0
size 159424
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bc9b7331d0c87575afdda93b12f8abc427ce71807d55facd2829c7b0bdd6141
size 78957
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:007ae459fcf2deb6bea1438dc0bb70ddef423a86a3727d0a0cbf2d6b03faf4bf
size 114834
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d3f9ac2da64c6074721514cc8b93d4e1cf05240aea28fe690a1a6484f7a9443
size 145926
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7817cbafd5804842146c99ee173439148de27f12383d31453e6bcb42efe0710a
size 179448
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a19c30026c415e7e9863fa0981aa2c099af74d3f5989080b5f30dfba1fc2a16
size 88935
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78d88f95b904dde538144d3f4ea90367f7e43e19b67e523344d54f7f7fe5df4c
size 128075
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ed9a41aee39af26d15a6029ca234752bc6ce05c657bc823cf872f6f6f96c6fa oid sha256:a424225295a47e2a36259db2853d3b3614fa692998fe20b5a5ba38907c5c360e
size 122692 size 122887
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:18ce471267234a27ac322548545205dbfa765b167280d4315062235f0805c21d oid sha256:fe6acd808e37f633beb64ea2c8adff746ed396c4d9233f26a158bceac35ebde7
size 132827 size 132741
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fb8d177b329c8dfa85e33d01790f5086aebe98e12f6743aca82ff20ce8d231a9 oid sha256:b4a1f3b1e53caf4161530cb06d814be1107844fe4e50e698407c1f0c081dc8c3
size 62075 size 62469
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4b9912afa328dbd1cbc5867e2ce286799c230671d5403371381dc2ed55bdc2e0 oid sha256:bb133954fb0bcda46bc45426000e67da92c4f7e4ffe2a2928a789a34b84e7a4b
size 78024 size 80418
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:05f9fcaecd47a40388838e9fb89b4ae5d0c334ccc09a1578c724eed0993787fb oid sha256:03ade7d64d525c1f7330fa91e2b02380a9a144ca9260b673048425c9252300b0
size 98081 size 98333
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:44835ad6a27b78ade624a218a12df47e89e6737f7ebaae08d2c3ea50aea2afd1 oid sha256:465781cf576ef369b7e2e630503952d7847281bb44a14f0126e3c0e79d95ce1f
size 98950 size 98918
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9341836edad19787c281a50d70ca70744424cc879d383eece89a36562dda01df oid sha256:d6a437dbbd5255f02ecce3ed2a29d7620257468475c70d7e31f8fc74aa9ea6e4
size 40463 size 40815
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:42fc1d64b9a59f7566e06a57352f5ad32af23a5a514d7a2e70682175ea516165 oid sha256:02a212502b77bb966c5a0af6fabf319bdac8ac6b81686e992ee12e85b537a254
size 44753 size 47131
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0dc2792601fe8379ba95205e54af0feb265b89b98cafcf04050d73c6243caaa7
size 243770
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66501da9f7352a34a1704744357c6df7baa30b8eb9af3c868619183233e7863f
size 267320
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e07ab39faf3b92f048cc74c2a1f6e67679e83ed0da443899dfc856c046889399
size 177054
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:980d63529998d5801b39d1c205499a9ce9bfa811da5ba36cbe4bf91624e363d1
size 202584
@@ -24,7 +24,7 @@ final class AVMetadataMachineReadableCodeObjectExtensionsTest: XCTestCase {
return return
} }
guard let resultData = AVMetadataMachineReadableCodeObject.removeQrProtocolData(data, symbolVersion: symbolVersion) else { guard let resultData = try? AVMetadataMachineReadableCodeObject.removeQRProtocolData(data, symbolVersion: symbolVersion) else {
XCTFail("Could not remove the protocol data") XCTFail("Could not remove the protocol data")
return return
} }
@@ -15,7 +15,7 @@ import MatrixRustSDK
@MainActor @MainActor
final class QRCodeLoginScreenViewModelTests: XCTestCase { final class QRCodeLoginScreenViewModelTests: XCTestCase {
private var qrProgressSubject: PassthroughSubject<QrLoginProgress, Never>! private var qrProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
private var qrServiceMock: QRCodeLoginServiceMock! private var qrServiceMock: QRCodeLoginServiceMock!
private var appMediatorMock: AppMediatorMock! private var appMediatorMock: AppMediatorMock!
private var viewModel: QRCodeLoginScreenViewModelProtocol! private var viewModel: QRCodeLoginScreenViewModelProtocol!
@@ -25,17 +25,17 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
} }
override func setUp() { override func setUp() {
qrProgressSubject = PassthroughSubject<QrLoginProgress, Never>() qrProgressSubject = .init(.starting)
qrServiceMock = QRCodeLoginServiceMock() qrServiceMock = QRCodeLoginServiceMock()
qrServiceMock.underlyingQrLoginProgressPublisher = qrProgressSubject.eraseToAnyPublisher() qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher()
appMediatorMock = AppMediatorMock.default appMediatorMock = AppMediatorMock.default
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: qrServiceMock, viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock),
canSignInManually: true, canSignInManually: true,
appMediator: appMediatorMock) appMediator: appMediatorMock)
} }
func testInitialState() { func testInitialState() {
XCTAssertEqual(context.viewState.state, .initial) XCTAssertEqual(context.viewState.state, .loginInstructions)
XCTAssertNil(context.qrResult) XCTAssertNil(context.qrResult)
XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled) XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled)
XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled) XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled)
@@ -44,7 +44,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
func testRequestCameraPermission() async throws { func testRequestCameraPermission() async throws {
appMediatorMock.requestAuthorizationIfNeededReturnValue = false appMediatorMock.requestAuthorizationIfNeededReturnValue = false
XCTAssert(context.viewState.state == .initial) XCTAssert(context.viewState.state == .loginInstructions)
let deferred = deferFulfillment(viewModel.context.$viewState) { state in let deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.state == .error(.noCameraPermission) state.state == .error(.noCameraPermission)
@@ -60,15 +60,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
} }
func testLogin() async throws { func testLogin() async throws {
var isCompleted = false XCTAssert(context.viewState.state == .loginInstructions)
qrServiceMock.loginWithQRCodeDataClosure = { _ in
while !isCompleted {
await Task.yield()
}
return .success(UserSessionMock(.init(clientProxy: ClientProxyMock())))
}
XCTAssert(context.viewState.state == .initial)
var deferred = deferFulfillment(context.$viewState) { state in var deferred = deferFulfillment(context.$viewState) { state in
state.state == .scan(.scanning) state.state == .scan(.scanning)
@@ -97,14 +89,11 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
switch action { switch action {
case .done: case .signedIn: true
return true default: false
default:
return false
} }
} }
qrProgressSubject.send(.done) qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
isCompleted = true
try await deferredAction.fulfill() try await deferredAction.fulfill()
} }
} }