* 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.
240 lines
8.9 KiB
Swift
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)
|
|
}
|
|
}
|