QR Code Login Flow (#2767)

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
This commit is contained in:
Mauro
2024-05-29 17:07:17 +02:00
committed by GitHub
parent 66a8f6f3d1
commit 4e23f7be22
72 changed files with 1241 additions and 226 deletions
@@ -31,9 +31,13 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
self.appSettings = appSettings
super.init(initialViewState: AuthenticationStartScreenViewState())
if !ProcessInfo.processInfo.isiOSAppOnMac {
appSettings.$qrCodeLoginEnabled
.weakAssign(to: \.state.isQRCodeLoginEnabled, on: self)
.store(in: &cancellables)
if appSettings.isDevelopmentBuild {
state.isQRCodeLoginEnabled = true
} else {
appSettings.$qrCodeLoginEnabled
.weakAssign(to: \.state.isQRCodeLoginEnabled, on: self)
.store(in: &cancellables)
}
}
}
@@ -27,6 +27,8 @@ struct QRCodeLoginScreenCoordinatorParameters {
enum QRCodeLoginScreenCoordinatorAction {
case cancel
case signInManually
case done(userSession: UserSessionProtocol)
}
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
@@ -52,8 +54,12 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .signInManually:
self.actionsSubject.send(.signInManually)
case .cancel:
self.actionsSubject.send(.cancel)
case .done(let userSession):
self.actionsSubject.send(.done(userSession: userSession))
}
}
.store(in: &cancellables)
@@ -18,6 +18,8 @@ import Foundation
enum QRCodeLoginScreenViewModelAction {
case cancel
case signInManually
case done(userSession: UserSessionProtocol)
}
struct QRCodeLoginScreenViewState: BindableState {
@@ -44,11 +46,18 @@ struct QRCodeLoginScreenViewState: BindableState {
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem2),
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem3)
]
var bindings = QRCodeLoginScreenViewStateBindings()
}
struct QRCodeLoginScreenViewStateBindings {
var qrResult: Data?
}
enum QRCodeLoginScreenViewAction {
case cancel
case startScan
case signInManually
case openSettings
}
@@ -65,6 +74,11 @@ enum QRCodeLoginState: Equatable {
enum QRCodeLoginErrorState: Equatable {
case noCameraPermission
case connectionNotSecure
case cancelled
case declined
case expired
case linkingNotSupported
case deviceNotSupported
case unknown
}
@@ -75,6 +89,8 @@ enum QRCodeLoginState: Equatable {
case connecting
/// the qr code has been processed and is invalid
case invalid
/// the qr code has been processed but it belongs to a device not signed in,
case deviceNotSignedIn
}
enum QRCodeLoginDisplayCodeState: Equatable {
@@ -99,4 +115,42 @@ enum QRCodeLoginState: Equatable {
return false
}
}
var isScanning: Bool {
switch self {
case .scan(let state):
return state == .scanning
default:
return false
}
}
var isError: Bool {
switch self {
case .error:
return true
case let .scan(state):
switch state {
case .invalid, .deviceNotSignedIn:
return true
default:
return false
}
default:
return false
}
}
var shouldDisplayCancelButton: Bool {
switch self {
case .initial:
return true
case .scan:
return true
case .error(let error):
return error == .noCameraPermission
default:
return false
}
}
}
@@ -14,9 +14,8 @@
// limitations under the License.
//
import AVFoundation
import Combine
import SwiftUI
import Foundation
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
@@ -28,12 +27,15 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var scanTask: Task<Void, Never>?
init(qrCodeLoginService: QRCodeLoginServiceProtocol,
appMediator: AppMediatorProtocol) {
self.qrCodeLoginService = qrCodeLoginService
self.appMediator = appMediator
super.init(initialViewState: QRCodeLoginScreenViewState())
setupSubscriptions()
}
// MARK: - Public
@@ -46,16 +48,109 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
Task { await startScanIfPossible() }
case .openSettings:
appMediator.openAppSettings()
case .signInManually:
actionsSubject.send(.signInManually)
}
}
private func startScanIfPossible() async {
state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
// MARK: - Private
private func setupSubscriptions() {
context.$viewState
// not using compactMap before remove duplicates because if there is an error, and the same code needs to be rescanned the transition to nil to clean the state would get ignored.
.map(\.bindings.qrResult)
.removeDuplicates()
.compactMap { $0 }
// this needs to be received on the main actor or the state change for connecting won't work properly
.receive(on: DispatchQueue.main)
.sink { [weak self] qrData in
self?.handleScan(qrData: qrData)
}
.store(in: &cancellables)
qrCodeLoginService.qrLoginProgressPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] progress in
MXLog.info("QR Login Progress changed to: \(progress)")
guard let self,
// Let's not advance the state if the current state is already invalid
!state.state.isError else {
return
}
switch progress {
case .establishingSecureChannel(_, let stringCode):
state.state = .displayCode(.deviceCode(stringCode))
case .waitingForToken(let code):
state.state = .displayCode(.verificationCode(code))
default:
break
}
}
.store(in: &cancellables)
}
private func startScanIfPossible() async {
state.bindings.qrResult = nil
state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
}
private func handleScan(qrData: Data) {
guard scanTask == nil else {
return
}
state.state = .scan(.connecting)
scanTask = Task { [weak self] in
guard let self else {
return
}
defer {
scanTask = nil
}
MXLog.info("Scanning QR code: \(qrData)")
switch await qrCodeLoginService.loginWithQRCode(data: qrData) {
case let .success(session):
MXLog.info("QR Login completed")
actionsSubject.send(.done(userSession: session))
case .failure(let error):
handleError(error: error)
}
}
}
private func handleError(error: QRCodeLoginServiceError) {
MXLog.error("Failed to scan the QR code: \(error)")
switch error {
case .invalidQRCode:
state.state = .scan(.invalid)
case .deviceNotSignedIn:
state.state = .scan(.deviceNotSignedIn)
case .cancelled:
state.state = .error(.cancelled)
case .connectionInsecure:
state.state = .error(.connectionNotSecure)
case .declined:
state.state = .error(.declined)
case .linkingNotSupported:
state.state = .error(.linkingNotSupported)
case .expired:
state.state = .error(.expired)
case .deviceNotSupported:
state.state = .error(.deviceNotSupported)
case .failedLoggingIn, .unknown:
state.state = .error(.unknown)
}
}
/// Only for mocking initial states
fileprivate init(state: QRCodeLoginState) {
qrCodeLoginService = QRCodeLoginServiceMock(configuration: .init())
qrCodeLoginService = QRCodeLoginServiceMock()
appMediator = AppMediatorMock.default
super.init(initialViewState: .init(state: state))
}
@@ -62,7 +62,7 @@ struct QRCodeLoginScreen: View {
SFNumberedListView(items: context.viewState.initialStateListItems)
}
} bottomContent: {
Button(L10n.actionContinue) {
Button(L10n.screenQrCodeLoginInitialStateButtonTitle) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
@@ -178,8 +178,11 @@ struct QRCodeLoginScreen: View {
}
.buttonStyle(.compound(.primary))
VStack(spacing: 0) {
Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle, icon: \.error, iconSize: .medium, relativeTo: .compound.bodyMDSemibold)
VStack(spacing: 4) {
Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle,
icon: \.error,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
@@ -187,6 +190,29 @@ struct QRCodeLoginScreen: View {
Text(L10n.screenQrCodeLoginInvalidScanStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
}
}
case .deviceNotSignedIn:
VStack(spacing: 16) {
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
VStack(spacing: 4) {
Label(L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle,
icon: \.error,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
Text(L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
}
}
}
@@ -194,7 +220,7 @@ struct QRCodeLoginScreen: View {
}
private var qrScanner: some View {
QRCodeScannerView()
QRCodeScannerView(result: $context.qrResult, isScanning: context.viewState.state.isScanning)
.aspectRatio(1.0, contentMode: .fill)
.frame(maxWidth: 312)
.readFrame($qrFrame)
@@ -207,7 +233,7 @@ struct QRCodeLoginScreen: View {
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
if !context.viewState.state.isDisplayingCode {
if context.viewState.state.shouldDisplayCancelButton {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
@@ -273,25 +299,65 @@ struct QRCodeLoginScreen: View {
SFNumberedListView(items: context.viewState.connectionNotSecureListItems)
}
}
default:
simpleErrorStack(errorState: errorState)
}
}
@ViewBuilder
private func simpleErrorStack(errorState: QRCodeLoginState.QRCodeLoginErrorState) -> some View {
let title = switch errorState {
case .cancelled:
L10n.screenQrCodeLoginErrorCancelledTitle
case .declined:
L10n.screenQrCodeLoginErrorDeclinedTitle
case .expired:
L10n.screenQrCodeLoginErrorExpiredTitle
case .linkingNotSupported:
L10n.screenQrCodeLoginErrorLinkingNotSuportedTitle
case .deviceNotSupported:
L10n.screenQrCodeLoginErrorSlidingSyncNotSupportedTitle(InfoPlistReader.main.bundleDisplayName)
case .unknown:
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
L10n.commonSomethingWentWrong
default:
fatalError("This should not be displayed")
}
let subtitle: String = switch errorState {
case .cancelled:
L10n.screenQrCodeLoginErrorCancelledSubtitle
case .declined:
L10n.screenQrCodeLoginErrorDeclinedSubtitle
case .expired:
L10n.screenQrCodeLoginErrorExpiredSubtitle
case .linkingNotSupported:
L10n.screenQrCodeLoginErrorLinkingNotSuportedSubtitle(InfoPlistReader.main.bundleDisplayName)
case .deviceNotSupported:
L10n.screenQrCodeLoginErrorSlidingSyncNotSupportedSubtitle(InfoPlistReader.main.bundleDisplayName)
case .unknown:
L10n.screenQrCodeLoginUnknownErrorDescription
default:
fatalError("This should not be displayed")
}
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
VStack(spacing: 8) {
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Text(L10n.commonSomethingWentWrong)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginUnknownErrorDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
Text(subtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
}
@ViewBuilder
private func errorContentFooter(errorState: QRCodeLoginState.QRCodeLoginErrorState) -> some View {
switch errorState {
case .noCameraPermission:
@@ -299,11 +365,30 @@ struct QRCodeLoginScreen: View {
context.send(viewAction: .openSettings)
}
.buttonStyle(.compound(.primary))
case .connectionNotSecure, .unknown:
case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported:
Button(L10n.screenQrCodeLoginStartOverButton) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
case .cancelled:
Button(L10n.actionTryAgain) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
case .linkingNotSupported:
VStack(spacing: 16) {
Button(L10n.screenOnboardingSignInManually) {
context.send(viewAction: .signInManually)
}
.buttonStyle(.compound(.primary))
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
.padding(.vertical, 13)
.frame(maxWidth: .infinity)
.buttonStyle(.compound(.plain))
}
}
}
}
@@ -336,24 +421,40 @@ private struct QRScannerViewOverlay: View {
// MARK: - Previews
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
// Initial
static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial)
// Scanning
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting))
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid))
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.deviceNotSignedIn))
// Display Code
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
// Errors
static let noCameraPermissionStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.noCameraPermission))
static let connectionNotSecureStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.connectionNotSecure))
static let linkingUnsupportedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.linkingNotSupported))
static let cancelledStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.cancelled))
static let declinedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined))
static let expiredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.expired))
static let deviceNoSupportedViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.deviceNotSupported))
static let unknownErrorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.unknown))
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
static var previews: some View {
QRCodeLoginScreen(context: initialStateViewModel.context)
.previewDisplayName("Initial")
@@ -367,19 +468,37 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
QRCodeLoginScreen(context: invalidStateViewModel.context)
.previewDisplayName("Invalid")
QRCodeLoginScreen(context: noCameraPermissionStateViewModel.context)
.previewDisplayName("No Camera Permission")
QRCodeLoginScreen(context: connectionNotSecureStateViewModel.context)
.previewDisplayName("Connection not secure")
QRCodeLoginScreen(context: unknownErrorStateViewModel.context)
.previewDisplayName("Unknown error")
QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context)
.previewDisplayName("Device not signed in")
QRCodeLoginScreen(context: deviceCodeStateViewModel.context)
.previewDisplayName("Device code")
QRCodeLoginScreen(context: verificationCodeStateViewModel.context)
.previewDisplayName("Verification code")
QRCodeLoginScreen(context: noCameraPermissionStateViewModel.context)
.previewDisplayName("No Camera Permission")
QRCodeLoginScreen(context: connectionNotSecureStateViewModel.context)
.previewDisplayName("Connection not secure")
QRCodeLoginScreen(context: linkingUnsupportedStateViewModel.context)
.previewDisplayName("Linking unsupported")
QRCodeLoginScreen(context: cancelledStateViewModel.context)
.previewDisplayName("Cancelled")
QRCodeLoginScreen(context: declinedStateViewModel.context)
.previewDisplayName("Declined")
QRCodeLoginScreen(context: expiredStateViewModel.context)
.previewDisplayName("Expired")
QRCodeLoginScreen(context: deviceNoSupportedViewModel.context)
.previewDisplayName("Device not supported")
QRCodeLoginScreen(context: unknownErrorStateViewModel.context)
.previewDisplayName("Unknown error")
}
}
@@ -19,13 +19,48 @@ import SwiftUI
import UIKit
struct QRCodeScannerView: UIViewControllerRepresentable {
@Binding var result: Data?
var isScanning: Bool
func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController()
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_ uiViewController: QRScannerController, context: Context) { }
func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {
if isScanning {
uiViewController.startScan()
} else {
uiViewController.stopScan()
}
}
func makeCoordinator() -> Coordinator {
Coordinator($result)
}
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
@Binding var scanResult: Data?
init(_ scanResult: Binding<Data?>) {
_scanResult = scanResult
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
// Check if the metadataObjects array is not nil and it contains at least one object.
guard metadataObjects.count > 0,
let metadataObj = metadataObjects[0] as? AVMetadataMachineReadableCodeObject,
metadataObj.type == AVMetadataObject.ObjectType.qr,
let data = metadataObj.binaryValue else {
MXLog.info("QRCodeScannerView: invalid qr scan")
return
}
scanResult = data
MXLog.info("QRCodeScannerView: scanned data")
}
}
}
final class QRScannerController: UIViewController {
@@ -33,8 +68,8 @@ final class QRScannerController: UIViewController {
private var videoPreviewLayer: AVCaptureVideoPreviewLayer?
private var qrCodeFrameView: UIView?
var delegate: AVCaptureMetadataOutputObjectsDelegate?
weak var delegate: AVCaptureMetadataOutputObjectsDelegate?
override func viewDidLoad() {
super.viewDidLoad()
@@ -73,15 +108,26 @@ final class QRScannerController: UIViewController {
videoPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
videoPreviewLayer?.frame = view.layer.bounds
view.layer.addSublayer(previewLayer)
// Start video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
videoPreviewLayer?.frame = view.layer.bounds
}
func startScan() {
// Start video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
MXLog.info("QRCodeScannerView: capture session started")
}
}
func stopScan() {
// Stop video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.stopRunning()
MXLog.info("QRCodeScannerView: capture session stopped")
}
}
}
@@ -39,12 +39,6 @@ struct DeveloperOptionsScreen: View {
}
}
Section("QR Code") {
Toggle(isOn: $context.qrCodeLoginEnabled) {
Text("QR code login")
}
}
Section("Room") {
Toggle(isOn: $context.shouldCollapseRoomStateEvents) {
Text("Collapse room state events")