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

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