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 {