Link new device with QR code tweaks. (#5563)

* Hook up Rust's InvalidCheckCode failure to the right state when linking a new device.

* Automatically regenerate a new QR code instead of ending the flow when it expires.
This commit is contained in:
Doug
2026-05-11 14:22:34 +01:00
committed by GitHub
parent ded86dd66e
commit e235e3ba81
11 changed files with 106 additions and 31 deletions
@@ -51,7 +51,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .linkMobileDevice(let progressPublisher):
presentQRCodeScreen(mode: .linkMobile(progressPublisher))
presentQRCodeScreen(mode: .linkMobile(progressPublisher, flowParameters.userSession.clientProxy))
case .linkDesktopComputer:
presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService()))
case .dismiss:
@@ -39,7 +39,7 @@ enum QRCodeLoginScreenMode {
/// Configures the screen to link another device by scanning a QR code.
case linkDesktop(LinkNewDeviceServiceProtocol)
/// Configures the screen to link another device by showing it a QR code.
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher, ClientProxyProtocol)
}
struct QRCodeLoginScreenViewState: BindableState {
@@ -107,7 +107,7 @@ enum QRCodeLoginState: Equatable {
case displayCode(DisplayCodeState)
/// Initial state where the user can link another device using the shown QR code.
case displayQR(UIImage)
case displayQR(DisplayQRState)
/// The user needs to enter the two digit code to confirm the channel is secure
case confirmCode(CheckCodeState)
@@ -171,6 +171,13 @@ enum QRCodeLoginState: Equatable {
}
}
enum DisplayQRState: Equatable {
/// The QR code is available and is being shown.
case active(UIImage)
/// The QR code being shown has expired. We will need to generate a new one.
case expired
}
enum DisplayCodeState: Equatable {
case deviceCode(String)
case verificationCode(String)
@@ -30,9 +30,9 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
let initialViewState: QRCodeLoginState = switch mode {
case .login: .loginInstructions
case .linkDesktop: .linkDesktopInstructions
case .linkMobile(let progressPublisher):
case .linkMobile(let progressPublisher, _):
switch progressPublisher.value {
case .qrReady(let image): .displayQR(image)
case .qrReady(let image): .displayQR(.active(image))
default: .error(.unknown)
}
}
@@ -40,7 +40,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
super.init(initialViewState: .init(state: initialViewState, mode: mode, canSignInManually: canSignInManually))
setupSubscriptions()
if case .linkMobile(let progressPublisher) = mode {
if case .linkMobile(let progressPublisher, _) = mode {
listenToDisplayQRProgress(progressPublisher: progressPublisher)
}
}
@@ -232,22 +232,49 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
}
}
private func regenerateQRCode() async {
guard case .linkMobile(_, let clientProxy) = state.mode else {
fatalError("Cannot display a QR code in this mode.")
}
let linkNewDeviceService = clientProxy.linkNewDeviceService()
let progressPublisher = linkNewDeviceService.linkMobileDevice()
do {
async let minimumDelay = Task.sleep(for: .seconds(1)) // To show the spinner for long enough so there isn't a flicker.
async let qrReadyProgress = progressPublisher.values
.first { progress in
switch progress {
case .qrReady: true
default: false
}
}
guard case .qrReady(let image) = try await (qrReadyProgress, minimumDelay).0 else {
state.state = .error(.unknown)
return
}
state.state = .displayQR(.active(image))
} catch let error as QRCodeLoginError {
handleError(error)
} catch {
handleError(.unknown)
}
listenToDisplayQRProgress(progressPublisher: progressPublisher)
}
private func sendCheckCode() async {
guard case let .confirmCode(.inputCode(checkCodeSender)) = state.state else {
fatalError("Attempting to check code from the wrong state.")
}
state.state = .confirmCode(.sendingCode)
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)
@@ -291,10 +318,15 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
state.state = .error(.cancelled)
case .connectionInsecure:
state.state = .error(.connectionNotSecure)
case .invalidCheckCode:
state.state = .confirmCode(.invalidCode)
case .declined:
state.state = .error(.declined)
case .linkingNotSupported:
state.state = .error(.linkingNotSupported)
case .expired where state.state.isDisplayQR:
state.state = .displayQR(.expired)
Task { await regenerateQRCode() }
case .expired:
state.state = .error(.expired)
case .slidingSyncNotAvailable:
@@ -207,19 +207,26 @@ struct QRCodeLoginScreen: View {
@ViewBuilder
private var displayQRContent: some View {
if case let .displayQR(image) = context.viewState.state {
if case let .displayQR(qrState) = 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)
.accessibilityLabel(L10n.a11yQrCode)
switch qrState {
case .active(let image):
Image(uiImage: image)
.interpolation(.none) // to stop it getting blurred
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.accessibilityLabel(L10n.a11yQrCode)
case .expired:
ProgressView()
.controlSize(.large)
.frame(width: 200, height: 200)
}
SFNumberedListView(items: context.viewState.instructions.linkMobileItems)
}
@@ -337,7 +344,8 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
/// Showing
static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(LinkNewDeviceServiceMock.mockQRCodeImage))
static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(.active(LinkNewDeviceServiceMock.mockQRCodeImage)))
static let showingExpiredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(.expired))
// Displaying codes
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
@@ -369,6 +377,8 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
ElementNavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) }
.previewDisplayName("Showing")
ElementNavigationStack { QRCodeLoginScreen(context: showingExpiredStateViewModel.context) }
.previewDisplayName("Showing expired")
ElementNavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) }
.previewDisplayName("Device code")
@@ -111,6 +111,7 @@ enum QRCodeLoginError: Error, Equatable {
case providerNotAllowed(scannedProvider: String, allowedProviders: [String])
case cancelled
case connectionInsecure
case invalidCheckCode
case declined
case linkingNotSupported
case expired
@@ -195,8 +195,10 @@ extension LinkNewDeviceService.LinkDesktopProgress: CustomStringConvertible {
private extension QRCodeLoginError {
init(rustError: HumanQrGrantLoginError) {
self = switch rustError {
case .InvalidCheckCode, .ConnectionInsecure:
case .ConnectionInsecure:
.connectionInsecure
case .InvalidCheckCode:
.invalidCheckCode
case .UnsupportedProtocol:
.linkingNotSupported
case .Expired, .NotFound, .DeviceNotFound:
@@ -227,13 +229,6 @@ class CheckCodeSenderProxy: Equatable {
func send(code: UInt8) async throws {
try await underlyingSender.send(code: code)
}
/// Bypassed for now whilst we wait for an SDK update (however its worth noting that
/// things should still fail if the wrong code is provided, just not necessarily with
/// the right error being shown). https://github.com/matrix-org/matrix-rust-sdk/pull/5957
func validate(checkCode: UInt8) -> Bool {
true
}
}
extension UIImage {
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:35b2338f432dd2d65581f94680382a33af87881bfd36e2a426a96f879ed1d93f
size 119474
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f34b4d5fb95d223b8b1cf850fe74fc75d031da0a7f0a359b5a4c3e29fa83669
size 142744
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c86cc8592649e4d3e65a6632fd02756455ffd48bbdc35b12d7db5a8ba147c988
size 70650
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2bbc55c8ae626a1e652864927dfa2eab2860c85dca9372cca73e0af982d7ca41
size 96693
@@ -9,6 +9,7 @@
import Combine
@testable import ElementX
import MatrixRustSDKMocks
import SwiftUI
import Testing
@MainActor
@@ -18,6 +19,7 @@ struct QRCodeLoginScreenViewModelTests {
var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
var qrCodeLoginService: QRCodeLoginServiceMock!
var regeneratedQRCodeImage: UIImage!
var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>!
var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>!
var linkNewDeviceService: LinkNewDeviceServiceMock!
@@ -202,6 +204,17 @@ struct QRCodeLoginScreenViewModelTests {
try await deferredAction.fulfill()
}
@Test
mutating func linkMobileDeviceQRCodeExpiry() async throws {
setup(mode: .linkMobile)
#expect(context.viewState.state.isDisplayQR)
let deferred = deferFulfillment(context.$viewState, keyPath: \.state, transitionValues: [.displayQR(.expired),
.displayQR(.active(regeneratedQRCodeImage))])
linkMobileProgressSubject.send(completion: .failure(.expired))
try await deferred.fulfill()
}
// MARK: - Helpers
private mutating func setup(mode: Mode) {
@@ -214,6 +227,11 @@ struct QRCodeLoginScreenViewModelTests {
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(),
linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher()))
regeneratedQRCodeImage = LinkNewDeviceServiceMock.mockQRCodeImage
let clientProxy = ClientProxyMock(.init())
clientProxy.linkNewDeviceServiceReturnValue = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: .init(.qrReady(regeneratedQRCodeImage)),
linkDesktopProgressPublisher: .init(.starting)))
let screenMode: QRCodeLoginScreenMode
switch mode {
case .login:
@@ -221,7 +239,7 @@ struct QRCodeLoginScreenViewModelTests {
case .linkDesktop:
screenMode = .linkDesktop(linkNewDeviceService)
case .linkMobile:
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice())
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice(), clientProxy)
}
appMediator = AppMediatorMock.default