From 7c839efffcff0b221dd9fd9e9104172a9f9e40c3 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:18:39 +0000 Subject: [PATCH] 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. --- .../AuthenticationFlowCoordinator.swift | 13 +- .../LinkNewDeviceFlowCoordinator.swift | 27 +- .../Mocks/Generated/GeneratedMocks.swift | 17 +- .../AVMetadataMachineReadableCodeObject.swift | 243 +++++++++------- .../LinkNewDeviceScreenCoordinator.swift | 2 +- .../LinkNewDeviceScreenModels.swift | 2 +- .../LinkNewDeviceScreenViewModel.swift | 2 +- .../QRCodeLoginScreenCoordinator.swift | 21 +- .../QRCodeLoginScreenModels.swift | 170 ++++++++--- .../QRCodeLoginScreenViewModel.swift | 271 ++++++++++++++---- .../View/QRCodeErrorView.swift | 10 +- .../View/QRCodeLoginScreen.swift | 192 ++++++++++--- .../View/QRCodeScannerView.swift | 16 +- .../AuthenticationService.swift | 59 ++-- .../AuthenticationServiceProtocol.swift | 57 +++- .../Authentication/LinkNewDeviceService.swift | 60 ++-- ...Screen.Confirm-code-entered-iPad-en-GB.png | 3 + ...creen.Confirm-code-entered-iPad-pseudo.png | 3 + ...reen.Confirm-code-entered-iPhone-en-GB.png | 3 + ...een.Confirm-code-entered-iPhone-pseudo.png | 3 + ...odeLoginScreen.Confirm-code-iPad-en-GB.png | 3 + ...deLoginScreen.Confirm-code-iPad-pseudo.png | 3 + ...eLoginScreen.Confirm-code-iPhone-en-GB.png | 3 + ...LoginScreen.Confirm-code-iPhone-pseudo.png | 3 + ...Screen.Confirm-code-invalid-iPad-en-GB.png | 3 + ...creen.Confirm-code-invalid-iPad-pseudo.png | 3 + ...reen.Confirm-code-invalid-iPhone-en-GB.png | 3 + ...een.Confirm-code-invalid-iPhone-pseudo.png | 3 + ...RCodeLoginScreen.Connecting-iPad-en-GB.png | 4 +- ...CodeLoginScreen.Connecting-iPad-pseudo.png | 4 +- ...odeLoginScreen.Connecting-iPhone-en-GB.png | 4 +- ...deLoginScreen.Connecting-iPhone-pseudo.png | 4 +- ...Screen.Device-not-signed-in-iPad-en-GB.png | 4 +- ...creen.Device-not-signed-in-iPad-pseudo.png | 4 +- ...reen.Device-not-signed-in-iPhone-en-GB.png | 4 +- ...een.Device-not-signed-in-iPhone-pseudo.png | 4 +- .../qRCodeLoginScreen.Invalid-iPad-en-GB.png | 4 +- .../qRCodeLoginScreen.Invalid-iPad-pseudo.png | 4 +- ...qRCodeLoginScreen.Invalid-iPhone-en-GB.png | 4 +- ...RCodeLoginScreen.Invalid-iPhone-pseudo.png | 4 +- ...ginScreen.Link-instructions-iPad-en-GB.png | 3 + ...inScreen.Link-instructions-iPad-pseudo.png | 3 + ...nScreen.Link-instructions-iPhone-en-GB.png | 3 + ...Screen.Link-instructions-iPhone-pseudo.png | 3 + ...inScreen.Login-instructions-iPad-en-GB.png | 3 + ...nScreen.Login-instructions-iPad-pseudo.png | 3 + ...Screen.Login-instructions-iPhone-en-GB.png | 3 + ...creen.Login-instructions-iPhone-pseudo.png | 3 + ...CodeLoginScreen.Not-allowed-iPad-en-GB.png | 4 +- ...odeLoginScreen.Not-allowed-iPad-pseudo.png | 4 +- ...deLoginScreen.Not-allowed-iPhone-en-GB.png | 4 +- ...eLoginScreen.Not-allowed-iPhone-pseudo.png | 4 +- .../qRCodeLoginScreen.Scanning-iPad-en-GB.png | 4 +- ...qRCodeLoginScreen.Scanning-iPad-pseudo.png | 4 +- ...RCodeLoginScreen.Scanning-iPhone-en-GB.png | 4 +- ...CodeLoginScreen.Scanning-iPhone-pseudo.png | 4 +- .../qRCodeLoginScreen.Showing-iPad-en-GB.png | 3 + .../qRCodeLoginScreen.Showing-iPad-pseudo.png | 3 + ...qRCodeLoginScreen.Showing-iPhone-en-GB.png | 3 + ...RCodeLoginScreen.Showing-iPhone-pseudo.png | 3 + ...hineReadableCodeObjectExtensionsTest.swift | 2 +- .../QRCodeLoginScreenViewModelTests.swift | 31 +- 62 files changed, 959 insertions(+), 388 deletions(-) create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-pseudo.png diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index 6c2b33e8e..782f528b4 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -298,7 +298,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { // MARK: - QR Code 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. orientationManager: appMediator.windowManager, appMediator: appMediator)) @@ -311,20 +312,24 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { navigationStackCoordinator.setSheetCoordinator(nil) stateMachine.tryEvent(.cancelledLoginWithQR) stateMachine.tryEvent(.confirmServer(.login)) - case .cancel: + case .dismiss: navigationStackCoordinator.setSheetCoordinator(nil) stateMachine.tryEvent(.cancelledLoginWithQR) - case .done(let userSession): + case .signedIn(let userSession): navigationStackCoordinator.setSheetCoordinator(nil) // Since the qr code login flow includes verification appSettings.hasRunIdentityConfirmationOnboarding = true DispatchQueue.main.async { self.stateMachine.tryEvent(.signedIn, userInfo: userSession) } + case .requestOIDCAuthorisation: + fatalError("QR code login shouldn't request an OIDC flow.") } } .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 diff --git a/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift index 7254eee43..45628d8ec 100644 --- a/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift @@ -50,9 +50,9 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol { switch action { case .linkMobileDevice(let progressPublisher): - break + presentQRCodeScreen(mode: .linkMobile(progressPublisher)) case .linkDesktopComputer: - break + presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService())) case .dismiss: actionsSubject.send(.dismiss) } @@ -61,4 +61,27 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol { 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) + } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 22f5f5d4b..41b6a18ce 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -13732,11 +13732,6 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol, @unchecked Sen } } class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable { - var qrLoginProgressPublisher: AnyPublisher { - get { return underlyingQrLoginProgressPublisher } - set(value) { underlyingQrLoginProgressPublisher = value } - } - var underlyingQrLoginProgressPublisher: AnyPublisher! //MARK: - loginWithQRCode @@ -13770,13 +13765,13 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable { var loginWithQRCodeDataReceivedData: Data? var loginWithQRCodeDataReceivedInvocations: [Data] = [] - var loginWithQRCodeDataUnderlyingReturnValue: Result! - var loginWithQRCodeDataReturnValue: Result! { + var loginWithQRCodeDataUnderlyingReturnValue: QRLoginProgressPublisher! + var loginWithQRCodeDataReturnValue: QRLoginProgressPublisher! { get { if Thread.isMainThread { return loginWithQRCodeDataUnderlyingReturnValue } else { - var returnValue: Result? = nil + var returnValue: QRLoginProgressPublisher? = nil DispatchQueue.main.sync { returnValue = loginWithQRCodeDataUnderlyingReturnValue } @@ -13794,16 +13789,16 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable { } } } - var loginWithQRCodeDataClosure: ((Data) async -> Result)? + var loginWithQRCodeDataClosure: ((Data) -> QRLoginProgressPublisher)? - func loginWithQRCode(data: Data) async -> Result { + func loginWithQRCode(data: Data) -> QRLoginProgressPublisher { loginWithQRCodeDataCallsCount += 1 loginWithQRCodeDataReceivedData = data DispatchQueue.main.async { self.loginWithQRCodeDataReceivedInvocations.append(data) } if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure { - return await loginWithQRCodeDataClosure(data) + return loginWithQRCodeDataClosure(data) } else { return loginWithQRCodeDataReturnValue } diff --git a/ElementX/Sources/Other/Extensions/AVMetadataMachineReadableCodeObject.swift b/ElementX/Sources/Other/Extensions/AVMetadataMachineReadableCodeObject.swift index 8405c5a74..3a48d9f62 100644 --- a/ElementX/Sources/Other/Extensions/AVMetadataMachineReadableCodeObject.swift +++ b/ElementX/Sources/Other/Extensions/AVMetadataMachineReadableCodeObject.swift @@ -7,130 +7,179 @@ // // 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 Foundation +enum AVMetadataBinaryValueError: Error { + case unhandledQRSegmentMode(UInt8) + case bitError(BitError) + case unknown(Error) +} + extension AVMetadataMachineReadableCodeObject { - var binaryValue: Data? { - switch type { - case .qr: - guard let binaryValueWithProtocol, - let symbolVersion = (descriptor as? CIQRCodeDescriptor)?.symbolVersion else { - return nil - } - 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 qrBinaryValue: Data? { + get throws(AVMetadataBinaryValueError) { + guard type == .qr else { return nil } + + guard let qrCodeDescriptor = descriptor as? CIQRCodeDescriptor else { return nil } + return try Self.removeQRProtocolData(qrCodeDescriptor.errorCorrectedPayload, symbolVersion: qrCodeDescriptor.symbolVersion) } } - - 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) throws(AVMetadataBinaryValueError) -> Data? { + var bits = input.bits() + var segment: [UInt8] + var output: [UInt8] = [] + + repeat { + segment = try takeSegment(&bits, version: symbolVersion) + output.append(contentsOf: segment) + } while !segment.isEmpty + + return Data(output) } - - 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) - 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()) + + private static func takeSegment(_ input: inout [Bit], version: Int) throws(AVMetadataBinaryValueError) -> [UInt8] { + do { + let mode = try input.takeBits(4) + + return switch mode { + case 0x02: try input.takeAlphanumericSegment(version) // Alphanumeric + case 0x04: try input.takeBinarySegment(version) // Binary + case 0x00: [] // End of data + default: throw AVMetadataBinaryValueError.unhandledQRSegmentMode(mode) } - for _ in 0.. UInt8 { - let left = remove(at: 0) - let right = remove(at: 0) - return UInt8(left, right) +private extension [Bit] { + mutating func takeBits(_ count: Int) throws(BitError) -> UInt8 { + guard count <= 8 else { throw .moreThan8BitsTaken } + + var value: UInt8 = 0 + for _ in 0.. 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) + mutating func takeBits16(_ count: Int) throws(BitError) -> UInt16 { + guard count <= 16 else { throw .moreThan16BitsTaken } + + var value: UInt16 = 0 + for _ in 0.. 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.. [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 { - func halfBytes() -> [HalfByte] { - var result = [HalfByte]() + func bits() -> [Bit] { + var result = [Bit]() forEach { (byte: UInt8) in - result.append(contentsOf: byte.halfBytes()) + result.append(contentsOf: byte.bits()) } return 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) + func bits() -> [Bit] { + var bits: [Bit] = [] + for i in 0..<8 { + let bitValue = (self >> (7 - i)) & 1 + bits.append(Bit(value: bitValue)) + } + return bits } } diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift index 4feee1b43..ec12170f0 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift @@ -9,7 +9,7 @@ import Combine import SwiftUI enum LinkNewDeviceScreenCoordinatorAction { - case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher) + case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher) case linkDesktopComputer case dismiss } diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift index a66b0b544..ea2613f64 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift @@ -8,7 +8,7 @@ import Foundation enum LinkNewDeviceScreenViewModelAction { - case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher) + case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher) case linkDesktopComputer case dismiss } diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift index 82185e310..60c7ebb2b 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift @@ -57,7 +57,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev let linkNewDeviceService = clientProxy.linkNewDeviceService() - let progressPublisher = linkNewDeviceService.generateQRCode() + let progressPublisher = linkNewDeviceService.linkMobileDevice() do { _ = try await progressPublisher.values diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift index 11012d1d4..7a69c18ec 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift @@ -10,16 +10,17 @@ import Combine import SwiftUI struct QRCodeLoginScreenCoordinatorParameters { - let qrCodeLoginService: QRCodeLoginServiceProtocol + let mode: QRCodeLoginScreenMode let canSignInManually: Bool let orientationManager: OrientationManagerProtocol let appMediator: AppMediatorProtocol } enum QRCodeLoginScreenCoordinatorAction { - case cancel + case dismiss case signInManually - case done(userSession: UserSessionProtocol) + case signedIn(userSession: UserSessionProtocol) + case requestOIDCAuthorisation(URL) } final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { @@ -34,7 +35,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { } init(parameters: QRCodeLoginScreenCoordinatorParameters) { - viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService, + viewModel = QRCodeLoginScreenViewModel(mode: parameters.mode, canSignInManually: parameters.canSignInManually, appMediator: parameters.appMediator) orientationManager = parameters.orientationManager @@ -47,11 +48,13 @@ 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)) + actionsSubject.send(.signInManually) + case .dismiss: + actionsSubject.send(.dismiss) + case .signedIn(let userSession): + actionsSubject.send(.signedIn(userSession: userSession)) + case .requestOIDCAuthorisation(let url): + actionsSubject.send(.requestOIDCAuthorisation(url)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift index 7f19e645f..265593a8e 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift @@ -6,62 +6,89 @@ // Please see LICENSE files in the repository root for full details. // -import Foundation +import SwiftUI enum QRCodeLoginScreenViewModelAction { - case cancel + case dismiss 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 { - 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 /// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false. let canSignInManually: Bool + let isPresentedModally: Bool - private static let initialStateListItem3AttributedText = { - 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) - ] - + let instructions = QRCodeLoginScreenInstructions() 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 { var qrResult: Data? + var checkCodeInput = "" } enum QRCodeLoginScreenViewAction { - case cancel + case dismiss case startScan + case sendCheckCode case errorAction(QRCodeErrorView.Action) } enum QRCodeLoginState: Equatable { - /// Initial state where the user is informed how to perform the scan - case initial - /// The camera is scanning + /// Initial state where the user is informed how to login this device by scanning a QR code. + case loginInstructions + /// 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) - /// Codes are being shown - case displayCode(QRCodeLoginDisplayCodeState) + /// Codes are being shown. + 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 case error(ErrorState) @@ -118,16 +145,30 @@ enum QRCodeLoginState: Equatable { } } - enum QRCodeLoginDisplayCodeState: Equatable { + enum DisplayCodeState: Equatable { case deviceCode(String) case verificationCode(String) var code: String { switch self { - case .deviceCode(let code): - return code - case .verificationCode(let code): - return code + case .deviceCode(let code): code + case .verificationCode(let code): 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 } } - - var shouldDisplayCancelButton: Bool { - switch self { - case .initial, .scan, .error(.noCameraPermission): true - default: false - } - } +} + +struct QRCodeLoginScreenInstructions { + private static let loginItem3 = { + 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) + ] } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift index cf78cc69d..d0ead6b95 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift @@ -12,7 +12,7 @@ import Foundation typealias QRCodeLoginScreenViewModelType = StateStoreViewModel class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol { - private let qrCodeLoginService: QRCodeLoginServiceProtocol + private let mode: QRCodeLoginScreenMode private let appMediator: AppMediatorProtocol private let actionsSubject: PassthroughSubject = .init() @@ -20,25 +20,53 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr actionsSubject.eraseToAnyPublisher() } - private var scanTask: Task? - - init(qrCodeLoginService: QRCodeLoginServiceProtocol, + private var currentTask: AnyCancellable? + + init(mode: QRCodeLoginScreenMode, canSignInManually: Bool, appMediator: AppMediatorProtocol) { - self.qrCodeLoginService = qrCodeLoginService + self.mode = mode 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() + + if case .linkMobile(let progressPublisher) = mode { + listenToDisplayQRProgress(progressPublisher: progressPublisher) + } } // MARK: - Public override func process(viewAction: QRCodeLoginScreenViewAction) { switch viewAction { - case .cancel, .errorAction(.cancel): - actionsSubject.send(.cancel) - case .startScan, .errorAction(.startScan): + case .dismiss, .errorAction(.dismiss): + actionsSubject.send(.dismiss) + case .startScan: 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): appMediator.openAppSettings() case .errorAction(.signInManually): @@ -50,36 +78,22 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr 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. + // 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 + guard let self else { return } + switch mode { + case .login(let qrCodeLoginService): + handleScan(qrData: qrData, loginService: qrCodeLoginService) + case .linkDesktop(let linkNewDeviceService): + handleScan(qrData: qrData, linkService: linkNewDeviceService) + case .linkMobile: + fatalError("A code should never be scanned when showing one.") } } .store(in: &cancellables) @@ -90,32 +104,160 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission) } - private func handleScan(qrData: Data) { - guard scanTask == nil else { - return - } + private func handleScan(qrData: Data, loginService: QRCodeLoginServiceProtocol) { + guard currentTask == nil else { return } state.state = .scan(.connecting) - scanTask = Task { [weak self] in - guard let self else { - return + MXLog.info("Login scanning QR code") + let progressPublisher = loginService.loginWithQRCode(data: qrData) + + 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 { - 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(.qrCodeError(let error)): - handleError(error) - case .failure: - handleError(.unknown) + MXLog.info("Link showing QR code.") + + 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, .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))) case .deviceNotSignedIn: state.state = .scan(.scanFailed(.deviceNotSignedIn)) - case .deviceAlreadySignedIn: - state.state = .error(.deviceAlreadySignedIn) case .cancelled: state.state = .error(.cancelled) case .connectionInsecure: @@ -142,21 +282,32 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr state.state = .error(.expired) case .deviceNotSupported: state.state = .error(.deviceNotSupported) + case .deviceAlreadySignedIn: + state.state = .error(.deviceAlreadySignedIn) case .unknown: state.state = .error(.unknown) } } /// Only for mocking initial states - fileprivate init(state: QRCodeLoginState, canSignInManually: Bool) { - qrCodeLoginService = QRCodeLoginServiceMock() + fileprivate init(state: QRCodeLoginState, canSignInManually: Bool, isPresentedModally: Bool, checkCodeInput: String) { + mode = .login(QRCodeLoginServiceMock()) 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 { - static func mock(state: QRCodeLoginState, canSignInManually: Bool = true) -> QRCodeLoginScreenViewModel { - QRCodeLoginScreenViewModel(state: state, canSignInManually: canSignInManually) + static func mock(state: QRCodeLoginState, + canSignInManually: Bool = true, + isPresentedModally: Bool = true, + checkCodeInput: String = "") -> QRCodeLoginScreenViewModel { + QRCodeLoginScreenViewModel(state: state, + canSignInManually: canSignInManually, + isPresentedModally: isPresentedModally, + checkCodeInput: checkCodeInput) } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeErrorView.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeErrorView.swift index 9cae38e76..288629e8f 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeErrorView.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeErrorView.swift @@ -12,7 +12,7 @@ struct QRCodeErrorView: View { let errorState: QRCodeLoginState.ErrorState let canSignInManually: Bool - enum Action { case openSettings, startScan, signInManually, cancel } + enum Action { case openSettings, startOver, signInManually, dismiss } let action: (Action) -> Void var title: String { @@ -127,12 +127,12 @@ struct QRCodeErrorView: View { .buttonStyle(.compound(.primary)) case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported: Button(L10n.screenQrCodeLoginStartOverButton) { - action(.startScan) + action(.startOver) } .buttonStyle(.compound(.primary)) case .cancelled: Button(L10n.actionTryAgain) { - action(.startScan) + action(.startOver) } .buttonStyle(.compound(.primary)) case .linkingNotSupported: @@ -145,13 +145,13 @@ struct QRCodeErrorView: View { } Button(L10n.actionCancel) { - action(.cancel) + action(.dismiss) } .buttonStyle(.compound(.tertiary)) } case .deviceAlreadySignedIn: Button(L10n.actionContinue) { - action(.cancel) + action(.dismiss) } .buttonStyle(.compound(.primary)) } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift index 0cb277aac..82aaa2203 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift @@ -11,7 +11,9 @@ import SwiftUI struct QRCodeLoginScreen: View { @ObservedObject var context: QRCodeLoginScreenViewModel.Context + @State private var qrFrame = CGRect.zero + @FocusState private var checkCodeInputFocus var backgroundStyle: Color { if case .error = context.viewState.state { @@ -22,25 +24,30 @@ struct QRCodeLoginScreen: View { } var body: some View { - NavigationStack { - mainContent - .toolbar { toolbar } - .toolbar(.visible, for: .navigationBar) - .background() - .backgroundStyle(backgroundStyle) - .interactiveDismissDisabled() - } + mainContent + .toolbar { toolbar } + .toolbar(.visible, for: .navigationBar) + .background() + .backgroundStyle(backgroundStyle) + .interactiveDismissDisabled() + .navigationBarBackButtonHidden(!context.viewState.shouldDisplayBackButton) } @ViewBuilder var mainContent: some View { switch context.viewState.state { - case .initial: - initialContent + case .loginInstructions: + loginInstructionsContent + case .linkDesktopInstructions: + linkDesktopInstructionsContent case .scan: - qrScanContent + qrScannerContent case .displayCode: displayCodeContent + case .displayQR: + displayQRContent + case .confirmCode: + confirmCodeContent case .error(let errorState): QRCodeErrorView(errorState: errorState, canSignInManually: context.viewState.canSignInManually) { action in 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) { VStack(alignment: .leading, spacing: 40) { TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName), @@ -56,7 +63,7 @@ struct QRCodeLoginScreen: View { icon: \.computer, iconStyle: .default) - SFNumberedListView(items: context.viewState.initialStateListItems) + SFNumberedListView(items: context.viewState.instructions.loginItems) } } bottomContent: { 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 private var displayCodeContent: some View { if case let .displayCode(displayCodeState) = context.viewState.state { @@ -92,14 +116,14 @@ struct QRCodeLoginScreen: View { } } bottomContent: { Button(L10n.actionCancel) { - context.send(viewAction: .cancel) + context.send(viewAction: .dismiss) } .buttonStyle(.compound(.secondary)) } } } - private func displayCodeHeader(state: QRCodeLoginState.QRCodeLoginDisplayCodeState) -> some View { + private func displayCodeHeader(state: QRCodeLoginState.DisplayCodeState) -> some View { switch state { case .deviceCode: TitleAndIcon(title: L10n.screenQrCodeLoginDeviceCodeTitle, @@ -114,7 +138,7 @@ struct QRCodeLoginScreen: View { } } - private var qrScanContent: some View { + private var qrScannerContent: some View { FullscreenDialog(topPadding: 24) { VStack(spacing: 40) { TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle, @@ -124,12 +148,12 @@ struct QRCodeLoginScreen: View { qrScanner } } bottomContent: { - qrScanFooter + qrScannerFooter } } @ViewBuilder - private var qrScanFooter: some View { + private var qrScannerFooter: some View { if case let .scan(scanState) = context.viewState.state { switch scanState { 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 private var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { - if context.viewState.state.shouldDisplayCancelButton { + if context.viewState.shouldDisplayCancelButton { Button(L10n.actionCancel) { - context.send(viewAction: .cancel) + context.send(viewAction: .dismiss) } } } @@ -216,15 +310,16 @@ private struct QRScannerViewOverlay: View { var body: some View { 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 struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { - // Initial - static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial) + // Instructions + static let loginInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .loginInstructions) + static let linkInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .linkDesktopInstructions) // 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))) - // Display Code - static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12"))) + // Showing + 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 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). static let errorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined)) static var previews: some View { - QRCodeLoginScreen(context: initialStateViewModel.context) - .previewDisplayName("Initial") + NavigationStack { QRCodeLoginScreen(context: loginInstructionsStateViewModel.context) } + .previewDisplayName("Login instructions") + NavigationStack { QRCodeLoginScreen(context: linkInstructionsStateViewModel.context) } + .previewDisplayName("Link instructions") - QRCodeLoginScreen(context: scanningStateViewModel.context) + NavigationStack { QRCodeLoginScreen(context: scanningStateViewModel.context) } .previewDisplayName("Scanning") - - QRCodeLoginScreen(context: connectingStateViewModel.context) + NavigationStack { QRCodeLoginScreen(context: connectingStateViewModel.context) } .previewDisplayName("Connecting") - - QRCodeLoginScreen(context: invalidStateViewModel.context) + NavigationStack { QRCodeLoginScreen(context: invalidStateViewModel.context) } .previewDisplayName("Invalid") - - QRCodeLoginScreen(context: notAllowedStateViewModel.context) + NavigationStack { QRCodeLoginScreen(context: notAllowedStateViewModel.context) } .previewDisplayName("Not allowed") - - QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context) + NavigationStack { QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context) } .previewDisplayName("Device not signed in") - QRCodeLoginScreen(context: deviceCodeStateViewModel.context) - .previewDisplayName("Device code") + NavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) } + .previewDisplayName("Showing") - QRCodeLoginScreen(context: verificationCodeStateViewModel.context) + NavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) } + .previewDisplayName("Device code") + NavigationStack { QRCodeLoginScreen(context: verificationCodeStateViewModel.context) } .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") } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift index 6c60fee90..2ed95615f 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift @@ -41,16 +41,18 @@ struct QRCodeScannerView: UIViewControllerRepresentable { 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") + guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject else { + MXLog.error("Invalid QR scan") return } - scanResult = data - MXLog.info("QRCodeScannerView: scanned data") + do { + let data = try metadataObject.qrBinaryValue + scanResult = data + MXLog.info("Scanned data") + } catch { + MXLog.error("Invalid QR code: \(error)") + } } } } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index 391c6721e..68c3282a8 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -24,11 +24,6 @@ class AuthenticationService: AuthenticationServiceProtocol { var homeserver: CurrentValuePublisher { homeserverSubject.asCurrentValuePublisher() } private(set) var flow: AuthenticationFlow - private let qrLoginProgressSubject = PassthroughSubject() - var qrLoginProgressPublisher: AnyPublisher { - qrLoginProgressSubject.eraseToAnyPublisher() - } - init(userSessionStore: UserSessionStoreProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol, clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(), @@ -153,43 +148,59 @@ class AuthenticationService: AuthenticationServiceProtocol { } } - func loginWithQRCode(data: Data) async -> Result { + func loginWithQRCode(data: Data) -> QRLoginProgressPublisher { + let progressSubject = CurrentValueSubject(.starting) + let qrData: QrCodeData do { qrData = try QrCodeData.fromBytes(bytes: data) } catch { 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 { 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) { 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 - self?.qrLoginProgressSubject.send(progress) + let listener = SDKListener { progress in + guard let progress = QRLoginProgress(rustProgress: progress) else { return } + progressSubject.send(progress) } - do { - let client = try await makeClient(homeserverAddress: scannedServerName) - let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue) - try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener) - return await userSession(for: client) - } catch let error as HumanQrLoginError { - MXLog.error("QRCode login error: \(error)") - return .failure(error.serviceError) - } catch RemoteSettingsError.elementProRequired(let serverName) { - return .failure(.elementProRequired(serverName: serverName)) - } catch { - MXLog.error("QRCode login unknown error: \(error)") - return .failure(.qrCodeError(.unknown)) + Task { + do { + let client = try await makeClient(homeserverAddress: scannedServerName) + let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue) + try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener) + + switch await userSession(for: client) { + case .success(let userSession): + progressSubject.send(.signedIn(userSession)) + case .failure(let error): + progressSubject.send(completion: .failure(error)) + } + } 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() { diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift index 62c33cac1..504e7ed7d 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProtocol.swift @@ -111,7 +111,58 @@ enum QRCodeLoginError: Error, Equatable { // sourcery: AutoMockable protocol QRCodeLoginServiceProtocol { - var qrLoginProgressPublisher: AnyPublisher { get } - - func loginWithQRCode(data: Data) async -> Result + typealias QRLoginProgressPublisher = CurrentValuePublisher + func loginWithQRCode(data: Data) -> QRLoginProgressPublisher +} + +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" + } + } } diff --git a/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift b/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift index 0bf7a266e..5dc236fe2 100644 --- a/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift +++ b/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift @@ -11,21 +11,25 @@ import MatrixRustSDK import SwiftUI class LinkNewDeviceService { - typealias GenerateProgressPublisher = CurrentValuePublisher - typealias ScanProgressPublisher = CurrentValuePublisher + /// Publishes the progress of linking a new device by showing it a QR code. + typealias LinkMobileProgressPublisher = CurrentValuePublisher + /// Publishes the progress of linking a new device by scanning a QR code generated by said device. + typealias LinkDesktopProgressPublisher = CurrentValuePublisher - enum GenerateProgress { + /// The progress of linking a new device by showing it a QR code. + enum LinkMobileProgress: Equatable { case starting case qrReady(UIImage) - case qrScanned(CheckCodeSenderProtocol) + case qrScanned(CheckCodeSenderProxy) case waitingForAuthorisation(verificationURL: URL) case syncingSecrets 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 establishingSecureChannel(checkCode: UInt8, checkCodeString: String) + case establishingSecureChannel(checkCodeString: String) case waitingForAuthorisation(verificationURL: URL) case syncingSecrets case done @@ -37,8 +41,9 @@ class LinkNewDeviceService { grantLoginHandler = handler } - func generateQRCode() -> GenerateProgressPublisher { - let progressSubject = CurrentValueSubject(.starting) + /// Links a new device by showing it a QR code. + func linkMobileDevice() -> LinkMobileProgressPublisher { + let progressSubject = CurrentValueSubject(.starting) let listener = SDKListener { do { try progressSubject.send(.init(rustProgress: $0)) @@ -64,8 +69,9 @@ class LinkNewDeviceService { return progressSubject.asCurrentValuePublisher() } - func scanQRCode(_ scannedQRData: Data) -> ScanProgressPublisher { - let progressSubject = CurrentValueSubject(.starting) + /// Links a new device using a QR code generated by said device. + func linkDesktopDevice(with scannedQRData: Data) -> LinkDesktopProgressPublisher { + let progressSubject = CurrentValueSubject(.starting) let listener = SDKListener { do { try progressSubject.send(.init(rustProgress: $0)) @@ -84,7 +90,8 @@ class LinkNewDeviceService { return progressSubject.asCurrentValuePublisher() } - #warning("Check intent/server name here…") + #warning("Check intent/server name??") + #warning("Check Element Pro here??") Task { do { @@ -103,7 +110,7 @@ class LinkNewDeviceService { } } -extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible { +extension LinkNewDeviceService.LinkMobileProgress: CustomStringConvertible { enum Error: Swift.Error { case invalidQRCodeData case invalidVerificationURI(String) @@ -118,7 +125,7 @@ extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible { } else { throw Error.invalidQRCodeData } - case .qrScanned(let checkCodeSender): .qrScanned(checkCodeSender) + case .qrScanned(let checkCodeSender): .qrScanned(.init(underlyingSender: checkCodeSender)) case .waitingForAuth(let verificationURI): // verificationURI is a String; ASWebAuthenticationSession requires a URL. 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) } init(rustProgress: GrantQrLoginProgress) throws { self = switch rustProgress { 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): // verificationURI is a String; ASWebAuthenticationSession requires a URL. 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) { let qrContext = CIContext() let qrFilter = CIFilter.qrCodeGenerator() diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-en-GB.png new file mode 100644 index 000000000..bff433d43 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a63a91ecf6e3637ec861f1100ebc97fd66abd3faeb014ae7812060f4400b82dc +size 112052 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-pseudo.png new file mode 100644 index 000000000..6a3748b4a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5143e2e544a6aca9373fa9680e2c3a7fbb28be55a77d9f283b0656d5f0340286 +size 132923 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-en-GB.png new file mode 100644 index 000000000..76b42437a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b12167a9d5d01b0f1c40ea742d1e3a6b0b5bfce9b712ab78367abbb0e91294e +size 65280 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-pseudo.png new file mode 100644 index 000000000..28ab5e34e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-entered-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4748af8b7a64f4c49253508662a7754727530c91a0fa62ec9d6006d13e84048 +size 86517 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-en-GB.png new file mode 100644 index 000000000..de956151e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17624efed44c446b37934f930dc1be97a6eabff196556707edc5b29d757bed3f +size 112327 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-pseudo.png new file mode 100644 index 000000000..c001dad16 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7581d78168eee58b20477e28e45f129bfce099e8671d19f7ed2bb3ba5cf07ce +size 133584 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-en-GB.png new file mode 100644 index 000000000..e24f8c537 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73423e5a5d8d101f9fdb5204d9f89a3d5881fe2897b8e6c676c2b5cf8ad819bd +size 65658 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-pseudo.png new file mode 100644 index 000000000..e781fe97e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3efadbacd57c47f8de49a5ffc6437e4bbad98b35455ddb4004238edfc31fa225 +size 86991 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-en-GB.png new file mode 100644 index 000000000..8ee4312a6 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75d2836225b86cc489ffb2e97d2b88a5a615497013c52a754e11d6064291efa7 +size 120243 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-pseudo.png new file mode 100644 index 000000000..64c6e25fe --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12e674f700020c1b8ec14d1dd281805bb551ffe2cf510fa9966fbd7bd99e02da +size 146278 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-en-GB.png new file mode 100644 index 000000000..10b42e5ba --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52f93b01f90d1287ec889a2416eb0881875276343a9f7cd42cc6041e4410574d +size 71887 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-pseudo.png new file mode 100644 index 000000000..1d778e219 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Confirm-code-invalid-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b6b686d6b4f544c143d4dfd71b0c66417de9b7e7bc0f99f5ef66f8bacaff2e9 +size 97854 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-en-GB.png index b95043dda..885b11344 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f2967fe12faab759a15dedd5285ecf29c072fea9de3558834a40481d2afd659 -size 105436 +oid sha256:2fbe2e5f121dfe92ae934f15e550f66fc752848d88a7cc403c26e7233b9300c6 +size 105657 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-pseudo.png index d3d964f98..9f854ccce 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b741a5591c5b0ea9371f00f177383aa04e3d23fa44341fdd30a8fb013fa9dac2 -size 109419 +oid sha256:3f580ac10e8da845e63f237cef93acdbca26ca480c6595a05d1098a1cb07da34 +size 109350 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-en-GB.png index 133b3bfa3..93ab6749f 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:131d0c3f463b81f0413c0c517c8b8f5a9cba4c786c77f9dae15cd62c39cc070f -size 46233 +oid sha256:f1efeedc569846e8d896de85c20da4876b278671ffcd1a30ae72f8ad10bc1314 +size 46576 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-pseudo.png index 5e3167328..377821afa 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Connecting-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47ebd411e4cc3a5100e72a0799c0b5f331025073eceaf35ace4063b06dda6e5c -size 53670 +oid sha256:a8e19b9a1790a26f7bd76f11a2b5425d3545584613f3e39eca3f109b65722ea4 +size 56081 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-en-GB.png index b6f0bc348..8191ff8a4 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a747e378979ffc936d8d3481ab31a5f5839a1544e736ccfe300aa56f42ffbf2 -size 122713 +oid sha256:9e3c13b00e7e452c71175cc262983cbf9a500acbcdd4f525d335759e0a0fda9a +size 122905 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-pseudo.png index f43603a82..f18f106b6 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3afd52dd8852df06eaa75d39d5a6c3542caacb852e174cc092d22af7665f554f -size 138264 +oid sha256:6b59ab75c3a03ade9121fc4fc0b24f87732d2ea222a99922bf39a57f9bb0175b +size 138177 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-en-GB.png index 4edc5efb1..bac1469b8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3da51241718c13a77b60150526f719ea95f3e93bf216cc48ebce119b71aea4b -size 64014 +oid sha256:3a2995fbb7b5339fcd062a3f811698697dcdbb264672aabb965181e5fa756529 +size 64409 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-pseudo.png index 73563d00c..132b6e54a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Device-not-signed-in-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3262cbaa505948ad223c1c91e2cbed5c495fa3598a8a5aa1b199988edf17d8f5 -size 81960 +oid sha256:9e94e59a5157ccc95dff6e528f826c1920d7ee95bd51738e26643ff815f53113 +size 84372 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-en-GB.png index 9b8e576fe..c70a7ee41 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16254261843f052920c95d965b3016819e0d865412d5ba6de5f591a71d2c8fc3 -size 117347 +oid sha256:85fb5855adedb446af1fc92f730a6fe2b4a53f48c2ec8c8028ef36d82e9cb6c3 +size 117537 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-pseudo.png index dfa62ee64..42db03fc8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:312f8605592eef5075c3da92d2f075b939da391ac1a41fdb0f025d1bc45478de -size 122574 +oid sha256:641136b99c2c39ab0ad2f842e43b5b589e15c8ac14c1bd10110535eb648921b4 +size 122486 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-en-GB.png index 916a2f7fd..689a87aa9 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af249b371b990aa1d04a05ae9d9b008e7c5557281eaf22a9275902f909e9af7d -size 56989 +oid sha256:4e5b1f5d79e1838bf4b6fee3cbec9e672a5446544ebd1e2c5c5f16126c896e12 +size 57386 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-pseudo.png index ef15ee28a..a2cfe6c15 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Invalid-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6fbce453be9a4de2ad06c5b09b549e9cc2f8295082f69245f555838ee290f50 -size 69272 +oid sha256:809a8b95dc396882887e2eae8c3df421adc962955e6a41cfa893b595a198ddbc +size 71703 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-en-GB.png new file mode 100644 index 000000000..755737d22 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19cfafc5a77aec22b3000647fdb3037a97ede0c372ea92ff8b7c13b96d9f522b +size 128392 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-pseudo.png new file mode 100644 index 000000000..ee8e10efa --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63f38ed565d61eae5efeec35e6855393862e60ed0c40a0033fc49cc1a289f1e0 +size 159424 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-en-GB.png new file mode 100644 index 000000000..df1730799 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bc9b7331d0c87575afdda93b12f8abc427ce71807d55facd2829c7b0bdd6141 +size 78957 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-pseudo.png new file mode 100644 index 000000000..89af92149 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Link-instructions-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:007ae459fcf2deb6bea1438dc0bb70ddef423a86a3727d0a0cbf2d6b03faf4bf +size 114834 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-en-GB.png new file mode 100644 index 000000000..43d8a1fde --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d3f9ac2da64c6074721514cc8b93d4e1cf05240aea28fe690a1a6484f7a9443 +size 145926 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-pseudo.png new file mode 100644 index 000000000..b4a15c611 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7817cbafd5804842146c99ee173439148de27f12383d31453e6bcb42efe0710a +size 179448 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-en-GB.png new file mode 100644 index 000000000..3ee8f1856 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a19c30026c415e7e9863fa0981aa2c099af74d3f5989080b5f30dfba1fc2a16 +size 88935 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-pseudo.png new file mode 100644 index 000000000..592ca092a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Login-instructions-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78d88f95b904dde538144d3f4ea90367f7e43e19b67e523344d54f7f7fe5df4c +size 128075 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png index 75a0b06da..a644e3fcb 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ed9a41aee39af26d15a6029ca234752bc6ce05c657bc823cf872f6f6f96c6fa -size 122692 +oid sha256:a424225295a47e2a36259db2853d3b3614fa692998fe20b5a5ba38907c5c360e +size 122887 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png index 6c5e957dd..ba157dd26 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18ce471267234a27ac322548545205dbfa765b167280d4315062235f0805c21d -size 132827 +oid sha256:fe6acd808e37f633beb64ea2c8adff746ed396c4d9233f26a158bceac35ebde7 +size 132741 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-en-GB.png index add77a68c..90a16720a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fb8d177b329c8dfa85e33d01790f5086aebe98e12f6743aca82ff20ce8d231a9 -size 62075 +oid sha256:b4a1f3b1e53caf4161530cb06d814be1107844fe4e50e698407c1f0c081dc8c3 +size 62469 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-pseudo.png index f0ccc9244..59b5c9080 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Not-allowed-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b9912afa328dbd1cbc5867e2ce286799c230671d5403371381dc2ed55bdc2e0 -size 78024 +oid sha256:bb133954fb0bcda46bc45426000e67da92c4f7e4ffe2a2928a789a34b84e7a4b +size 80418 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-en-GB.png index cfc2b52cc..f83320fe7 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05f9fcaecd47a40388838e9fb89b4ae5d0c334ccc09a1578c724eed0993787fb -size 98081 +oid sha256:03ade7d64d525c1f7330fa91e2b02380a9a144ca9260b673048425c9252300b0 +size 98333 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-pseudo.png index fa0ac79a2..34a4e8b70 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44835ad6a27b78ade624a218a12df47e89e6737f7ebaae08d2c3ea50aea2afd1 -size 98950 +oid sha256:465781cf576ef369b7e2e630503952d7847281bb44a14f0126e3c0e79d95ce1f +size 98918 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-en-GB.png index 3e8feada3..8d6536b22 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9341836edad19787c281a50d70ca70744424cc879d383eece89a36562dda01df -size 40463 +oid sha256:d6a437dbbd5255f02ecce3ed2a29d7620257468475c70d7e31f8fc74aa9ea6e4 +size 40815 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-pseudo.png index a7f9e6ce8..d3b4d02ca 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Scanning-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42fc1d64b9a59f7566e06a57352f5ad32af23a5a514d7a2e70682175ea516165 -size 44753 +oid sha256:02a212502b77bb966c5a0af6fabf319bdac8ac6b81686e992ee12e85b537a254 +size 47131 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-en-GB.png new file mode 100644 index 000000000..2542ae34d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dc2792601fe8379ba95205e54af0feb265b89b98cafcf04050d73c6243caaa7 +size 243770 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-pseudo.png new file mode 100644 index 000000000..eb6f08b38 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66501da9f7352a34a1704744357c6df7baa30b8eb9af3c868619183233e7863f +size 267320 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-en-GB.png new file mode 100644 index 000000000..b16a4a6a3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e07ab39faf3b92f048cc74c2a1f6e67679e83ed0da443899dfc856c046889399 +size 177054 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-pseudo.png new file mode 100644 index 000000000..d59eec0ab --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/qRCodeLoginScreen.Showing-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:980d63529998d5801b39d1c205499a9ce9bfa811da5ba36cbe4bf91624e363d1 +size 202584 diff --git a/UnitTests/Sources/AVMetadataMachineReadableCodeObjectExtensionsTest.swift b/UnitTests/Sources/AVMetadataMachineReadableCodeObjectExtensionsTest.swift index fec5163ee..215335716 100644 --- a/UnitTests/Sources/AVMetadataMachineReadableCodeObjectExtensionsTest.swift +++ b/UnitTests/Sources/AVMetadataMachineReadableCodeObjectExtensionsTest.swift @@ -24,7 +24,7 @@ final class AVMetadataMachineReadableCodeObjectExtensionsTest: XCTestCase { 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") return } diff --git a/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift b/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift index 2ec4cfe73..ea795f613 100644 --- a/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift +++ b/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift @@ -15,7 +15,7 @@ import MatrixRustSDK @MainActor final class QRCodeLoginScreenViewModelTests: XCTestCase { - private var qrProgressSubject: PassthroughSubject! + private var qrProgressSubject: CurrentValueSubject! private var qrServiceMock: QRCodeLoginServiceMock! private var appMediatorMock: AppMediatorMock! private var viewModel: QRCodeLoginScreenViewModelProtocol! @@ -25,17 +25,17 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { } override func setUp() { - qrProgressSubject = PassthroughSubject() + qrProgressSubject = .init(.starting) qrServiceMock = QRCodeLoginServiceMock() - qrServiceMock.underlyingQrLoginProgressPublisher = qrProgressSubject.eraseToAnyPublisher() + qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher() appMediatorMock = AppMediatorMock.default - viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: qrServiceMock, + viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock), canSignInManually: true, appMediator: appMediatorMock) } func testInitialState() { - XCTAssertEqual(context.viewState.state, .initial) + XCTAssertEqual(context.viewState.state, .loginInstructions) XCTAssertNil(context.qrResult) XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled) XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled) @@ -44,7 +44,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { func testRequestCameraPermission() async throws { appMediatorMock.requestAuthorizationIfNeededReturnValue = false - XCTAssert(context.viewState.state == .initial) + XCTAssert(context.viewState.state == .loginInstructions) let deferred = deferFulfillment(viewModel.context.$viewState) { state in state.state == .error(.noCameraPermission) @@ -60,15 +60,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { } func testLogin() async throws { - var isCompleted = false - qrServiceMock.loginWithQRCodeDataClosure = { _ in - while !isCompleted { - await Task.yield() - } - return .success(UserSessionMock(.init(clientProxy: ClientProxyMock()))) - } - - XCTAssert(context.viewState.state == .initial) + XCTAssert(context.viewState.state == .loginInstructions) var deferred = deferFulfillment(context.$viewState) { state in state.state == .scan(.scanning) @@ -97,14 +89,11 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in switch action { - case .done: - return true - default: - return false + case .signedIn: true + default: false } } - qrProgressSubject.send(.done) - isCompleted = true + qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock())))) try await deferredAction.fulfill() } }