mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -9,7 +9,7 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
enum LinkNewDeviceScreenCoordinatorAction {
|
||||
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
|
||||
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||
case linkDesktopComputer
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
|
||||
enum LinkNewDeviceScreenViewModelAction {
|
||||
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
|
||||
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||
case linkDesktopComputer
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user