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

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

* Refactor the QRCodeLoginService to work the same way as the LinkNewDeviceService.
This commit is contained in:
Doug
2026-01-07 12:18:39 +00:00
committed by GitHub
parent be651de9d7
commit 7c839efffc
62 changed files with 959 additions and 388 deletions

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -13732,11 +13732,6 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol, @unchecked Sen
}
}
class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
get { return underlyingQrLoginProgressPublisher }
set(value) { underlyingQrLoginProgressPublisher = value }
}
var underlyingQrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never>!
//MARK: - loginWithQRCode
@@ -13770,13 +13765,13 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
var loginWithQRCodeDataReceivedData: Data?
var loginWithQRCodeDataReceivedInvocations: [Data] = []
var loginWithQRCodeDataUnderlyingReturnValue: Result<UserSessionProtocol, AuthenticationServiceError>!
var loginWithQRCodeDataReturnValue: Result<UserSessionProtocol, AuthenticationServiceError>! {
var loginWithQRCodeDataUnderlyingReturnValue: QRLoginProgressPublisher!
var loginWithQRCodeDataReturnValue: QRLoginProgressPublisher! {
get {
if Thread.isMainThread {
return loginWithQRCodeDataUnderlyingReturnValue
} else {
var returnValue: Result<UserSessionProtocol, AuthenticationServiceError>? = nil
var returnValue: QRLoginProgressPublisher? = nil
DispatchQueue.main.sync {
returnValue = loginWithQRCodeDataUnderlyingReturnValue
}
@@ -13794,16 +13789,16 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
}
}
}
var loginWithQRCodeDataClosure: ((Data) async -> Result<UserSessionProtocol, AuthenticationServiceError>)?
var loginWithQRCodeDataClosure: ((Data) -> QRLoginProgressPublisher)?
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
func loginWithQRCode(data: Data) -> QRLoginProgressPublisher {
loginWithQRCodeDataCallsCount += 1
loginWithQRCodeDataReceivedData = data
DispatchQueue.main.async {
self.loginWithQRCodeDataReceivedInvocations.append(data)
}
if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure {
return await loginWithQRCodeDataClosure(data)
return loginWithQRCodeDataClosure(data)
} else {
return loginWithQRCodeDataReturnValue
}

View File

@@ -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..<charactersCount {
output.append(input.takeUInt8())
}
return output
case 0x00: // End of data
return []
default:
return []
} catch let error as BitError {
throw .bitError(error)
} catch let error as AVMetadataBinaryValueError {
throw error
} catch {
throw .unknown(error)
}
}
}
private struct HalfByte {
enum BitError: Error {
case moreThan8BitsTaken
case moreThan16BitsTaken
case unhandledAlphanumericCharacter(UInt8)
}
private struct Bit {
let value: UInt8
}
private extension [HalfByte] {
mutating func takeUInt8() -> 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..<count {
value = (value << 1) | remove(at: 0).value
}
return value
}
mutating func takeUInt16() -> UInt16 {
let first = remove(at: 0)
let second = remove(at: 0)
let third = remove(at: 0)
let fourth = remove(at: 0)
return UInt16(first, second, third, fourth)
mutating func takeBits16(_ count: Int) throws(BitError) -> UInt16 {
guard count <= 16 else { throw .moreThan16BitsTaken }
var value: UInt16 = 0
for _ in 0..<count {
value = (value << 1) | UInt16(remove(at: 0).value)
}
return value
}
mutating func takeUInt8() throws(BitError) -> UInt8 {
try takeBits(8)
}
mutating func takeUInt16() throws(BitError) -> UInt16 {
try takeBits16(16)
}
mutating func takeBinarySegment(_ version: Int) throws(BitError) -> [UInt8] {
let characterCountLength = version > 9 ? 16 : 8
let charactersCount = try takeBits16(characterCountLength)
var output = [UInt8]()
for _ in 0..<charactersCount {
try output.append(takeUInt8())
}
return output
}
mutating func takeAlphanumericSegment(_ version: Int) throws(BitError) -> [UInt8] {
let characterCountLength = version > 9 ? (version > 26 ? 13 : 11) : 9
let charactersCount = try takeBits16(characterCountLength)
var output = [UInt8]()
var charactersRemaining = charactersCount
while charactersRemaining > 1 {
if count < 11 {
// done
return output
}
// read the 11 bits
let nextTwoCharacters = try takeBits16(11)
// split into the two characters
try output.append(Self.alphaToByte(UInt8(nextTwoCharacters / 45)))
try output.append(Self.alphaToByte(UInt8(nextTwoCharacters % 45)))
charactersRemaining -= 2
}
if charactersRemaining == 1 {
if count < 6 {
// done
return output
}
let nextCharacter = try takeBits(6)
try output.append(Self.alphaToByte(nextCharacter))
}
return output
}
private static func alphaToByte(_ input: UInt8) throws(BitError) -> UInt8 {
let value: UInt8? = switch input {
case 0...9: input + 0x30 // 0-9
case 10...35: input - 10 + 0x41 // A-Z
case 36: " ".utf8.first
case 37: "$".utf8.first
case 38: "%".utf8.first
case 39: "*".utf8.first
case 40: "+".utf8.first
case 41: "-".utf8.first
case 42: ".".utf8.first
case 43: "/".utf8.first
case 44: ":".utf8.first
default: nil
}
guard let value else { throw .unhandledAlphanumericCharacter(input) }
return value
}
}
private extension Data {
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
}
}

View File

@@ -9,7 +9,7 @@ import Combine
import SwiftUI
enum LinkNewDeviceScreenCoordinatorAction {
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkDesktopComputer
case dismiss
}

View File

@@ -8,7 +8,7 @@
import Foundation
enum LinkNewDeviceScreenViewModelAction {
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkDesktopComputer
case dismiss
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
]
}

View File

@@ -12,7 +12,7 @@ import Foundation
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
private let qrCodeLoginService: QRCodeLoginServiceProtocol
private let mode: QRCodeLoginScreenMode
private let appMediator: AppMediatorProtocol
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
@@ -20,25 +20,53 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
actionsSubject.eraseToAnyPublisher()
}
private var scanTask: Task<Void, Never>?
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)
}
}

View File

@@ -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))
}

View File

@@ -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")
}
}

View File

@@ -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)")
}
}
}
}

View File

@@ -24,11 +24,6 @@ class AuthenticationService: AuthenticationServiceProtocol {
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
private(set) var flow: AuthenticationFlow
private let qrLoginProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
qrLoginProgressSubject.eraseToAnyPublisher()
}
init(userSessionStore: UserSessionStoreProtocol,
encryptionKeyProvider: EncryptionKeyProviderProtocol,
clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(),
@@ -153,43 +148,59 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
}
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
func loginWithQRCode(data: Data) -> QRLoginProgressPublisher {
let progressSubject = CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>(.starting)
let qrData: QrCodeData
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() {

View File

@@ -111,7 +111,58 @@ enum QRCodeLoginError: Error, Equatable {
// sourcery: AutoMockable
protocol QRCodeLoginServiceProtocol {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> { get }
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError>
typealias QRLoginProgressPublisher = CurrentValuePublisher<QRLoginProgress, AuthenticationServiceError>
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"
}
}
}

View File

@@ -11,21 +11,25 @@ import MatrixRustSDK
import SwiftUI
class LinkNewDeviceService {
typealias GenerateProgressPublisher = CurrentValuePublisher<GenerateProgress, QRCodeLoginError>
typealias ScanProgressPublisher = CurrentValuePublisher<ScanProgress, QRCodeLoginError>
/// Publishes the progress of linking a new device by showing it a QR code.
typealias LinkMobileProgressPublisher = CurrentValuePublisher<LinkMobileProgress, QRCodeLoginError>
/// Publishes the progress of linking a new device by scanning a QR code generated by said device.
typealias LinkDesktopProgressPublisher = CurrentValuePublisher<LinkDesktopProgress, QRCodeLoginError>
enum GenerateProgress {
/// The progress of linking a new device by showing it a QR code.
enum LinkMobileProgress: Equatable {
case starting
case 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<GenerateProgress, QRCodeLoginError>(.starting)
/// Links a new device by showing it a QR code.
func linkMobileDevice() -> LinkMobileProgressPublisher {
let progressSubject = CurrentValueSubject<LinkMobileProgress, QRCodeLoginError>(.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<ScanProgress, QRCodeLoginError>(.starting)
/// Links a new device using a QR code generated by said device.
func linkDesktopDevice(with scannedQRData: Data) -> LinkDesktopProgressPublisher {
let progressSubject = CurrentValueSubject<LinkDesktopProgress, QRCodeLoginError>(.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()

View File

@@ -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
}

View File

@@ -15,7 +15,7 @@ import MatrixRustSDK
@MainActor
final class QRCodeLoginScreenViewModelTests: XCTestCase {
private var qrProgressSubject: PassthroughSubject<QrLoginProgress, Never>!
private var qrProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
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<QrLoginProgress, Never>()
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()
}
}