Files
letro-ios/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift
Doug 15eae621b2 Add tests for linking a new device. (#4934)
* Replace GrantLoginWithQrCodeHandlerSDKMock with LinkNewDeviceServiceMock.

Add tests for all initial states on the QRCodeLoginScreen.

* Add tests for linking both mobile and desktop devices.

* Add UI tests for linking a new device.

* Don't show the Link Desktop Computer button when running on macOS.

This mirrors the decision to hide the Sign In With QR Code button on the start screen.
2026-01-09 13:10:14 +00:00

240 lines
8.9 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import CoreImage.CIFilterBuiltins
import MatrixRustSDK
import SwiftUI
// sourcery: AutoMockable
protocol LinkNewDeviceServiceProtocol {
/// Links a new device by showing it a QR code.
func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher
/// Links a new device using a QR code generated by said device.
func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher
}
class LinkNewDeviceService: LinkNewDeviceServiceProtocol {
/// 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>
/// The progress of linking a new device by showing it a QR code.
enum LinkMobileProgress: Equatable {
case starting
case qrReady(UIImage)
case qrScanned(CheckCodeSenderProxy)
case waitingForAuthorisation(verificationURL: URL)
case syncingSecrets
case done
}
/// The progress of linking a new device by scanning a QR code generated by said device.
enum LinkDesktopProgress: Equatable {
case starting
case establishingSecureChannel(checkCodeString: String)
case waitingForAuthorisation(verificationURL: URL)
case syncingSecrets
case done
}
private let grantLoginHandler: GrantLoginWithQrCodeHandlerProtocol
init(handler: GrantLoginWithQrCodeHandlerProtocol) {
grantLoginHandler = handler
}
/// 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))
} catch {
MXLog.error("Invalid GenerateProgress")
progressSubject.send(completion: .failure(.unknown))
}
}
Task {
do {
// TODO: we need a way to cancel the in progress grant if the user hit the cancel button
try await grantLoginHandler.generate(progressListener: listener) // The success state is handled by the listener.
} catch let error as HumanQrGrantLoginError {
MXLog.error("QR code reciprocate error: \(error)")
progressSubject.send(completion: .failure(.init(rustError: error)))
} catch {
MXLog.error("QR code reciprocate unknown error: \(error)")
progressSubject.send(completion: .failure(.unknown))
}
}
return progressSubject.asCurrentValuePublisher()
}
/// 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))
} catch {
MXLog.error("Invalid ScanProgress")
progressSubject.send(completion: .failure(.unknown))
}
}
let qrCodeData: QrCodeData
do {
qrCodeData = try QrCodeData.fromBytes(bytes: scannedQRData)
} catch {
MXLog.error("QR code decode error: \(error)")
progressSubject.send(completion: .failure(.invalidQRCode))
return progressSubject.asCurrentValuePublisher()
}
#warning("Check intent/server name??")
#warning("Check Element Pro here??")
Task {
do {
// TODO: it would be nice to be able to cancel the grant at the SDK level if the user hits the cancel button
try await grantLoginHandler.scan(qrCodeData: qrCodeData, progressListener: listener) // The success state is handled by the listener.
} catch let error as HumanQrGrantLoginError {
MXLog.error("QR code reciprocate error: \(error)")
progressSubject.send(completion: .failure(.init(rustError: error)))
} catch {
MXLog.error("QR code reciprocate unknown error: \(error)")
progressSubject.send(completion: .failure(.unknown))
}
}
return progressSubject.asCurrentValuePublisher()
}
}
extension LinkNewDeviceService.LinkMobileProgress: CustomStringConvertible {
enum Error: Swift.Error {
case invalidQRCodeData
case invalidVerificationURI(String)
}
init(rustProgress: GrantGeneratedQrLoginProgress) throws {
self = switch rustProgress {
case .starting: .starting
case .qrReady(let qrCode):
if let image = UIImage(qrCodeData: qrCode.toBytes()) {
.qrReady(image)
} else {
throw Error.invalidQRCodeData
}
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) {
.waitingForAuthorisation(verificationURL: url)
} else {
throw Error.invalidVerificationURI(verificationURI)
}
case .syncingSecrets: .syncingSecrets
case .done: .done
}
}
var description: String {
switch self {
case .starting: "starting"
case .qrReady: "qrReady"
case .qrScanned: "qrScanned"
case .waitingForAuthorisation: "waitingForAuthorisation"
case .syncingSecrets: "syncingSecrets"
case .done: "done"
}
}
}
extension LinkNewDeviceService.LinkDesktopProgress: CustomStringConvertible {
enum Error: Swift.Error { case invalidVerificationURI(String) }
init(rustProgress: GrantQrLoginProgress) throws {
self = switch rustProgress {
case .starting: .starting
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) {
.waitingForAuthorisation(verificationURL: url)
} else {
throw Error.invalidVerificationURI(verificationURI)
}
case .syncingSecrets: .syncingSecrets
case .done: .done
}
}
var description: String {
switch self {
case .starting: "starting"
case .establishingSecureChannel: "establishingSecureChannel"
case .waitingForAuthorisation: "waitingForAuthorisation"
case .syncingSecrets: "syncingSecrets"
case .done: "done"
}
}
}
private extension QRCodeLoginError {
init(rustError: HumanQrGrantLoginError) {
self = switch rustError {
case .InvalidCheckCode:
.connectionInsecure
case .UnsupportedProtocol:
.linkingNotSupported
case .Unknown, .NotFound, .MissingSecretsBackup, .DeviceIdAlreadyInUse, .UnableToCreateDevice:
.unknown
}
}
}
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()
qrFilter.message = qrCodeData
qrFilter.correctionLevel = "Q"
guard let outputImage = qrFilter.outputImage,
let cgImage = qrContext.createCGImage(outputImage, from: outputImage.extent) else {
MXLog.error("Failed to generate an image from the supplied QR code data.")
return nil
}
self.init(cgImage: cgImage)
}
}