mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -298,7 +298,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
// MARK: - QR Code
|
// MARK: - QR Code
|
||||||
|
|
||||||
private func showQRCodeLoginScreen() {
|
private func showQRCodeLoginScreen() {
|
||||||
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: authenticationService,
|
let stackCoordinator = NavigationStackCoordinator()
|
||||||
|
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(mode: .login(authenticationService),
|
||||||
canSignInManually: appSettings.allowOtherAccountProviders, // No need to worry about provisioning links as we hide QR login.
|
canSignInManually: appSettings.allowOtherAccountProviders, // No need to worry about provisioning links as we hide QR login.
|
||||||
orientationManager: appMediator.windowManager,
|
orientationManager: appMediator.windowManager,
|
||||||
appMediator: appMediator))
|
appMediator: appMediator))
|
||||||
@@ -311,20 +312,24 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||||
stateMachine.tryEvent(.cancelledLoginWithQR)
|
stateMachine.tryEvent(.cancelledLoginWithQR)
|
||||||
stateMachine.tryEvent(.confirmServer(.login))
|
stateMachine.tryEvent(.confirmServer(.login))
|
||||||
case .cancel:
|
case .dismiss:
|
||||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||||
stateMachine.tryEvent(.cancelledLoginWithQR)
|
stateMachine.tryEvent(.cancelledLoginWithQR)
|
||||||
case .done(let userSession):
|
case .signedIn(let userSession):
|
||||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||||
// Since the qr code login flow includes verification
|
// Since the qr code login flow includes verification
|
||||||
appSettings.hasRunIdentityConfirmationOnboarding = true
|
appSettings.hasRunIdentityConfirmationOnboarding = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.stateMachine.tryEvent(.signedIn, userInfo: userSession)
|
self.stateMachine.tryEvent(.signedIn, userInfo: userSession)
|
||||||
}
|
}
|
||||||
|
case .requestOIDCAuthorisation:
|
||||||
|
fatalError("QR code login shouldn't request an OIDC flow.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
navigationStackCoordinator.setSheetCoordinator(coordinator) // Don't use the callback (interactive dismiss disabled), choose the event with the action.
|
|
||||||
|
stackCoordinator.setRootCoordinator(coordinator)
|
||||||
|
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) // Don't use the callback (interactive dismiss disabled), choose the event with the action.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Manual Authentication
|
// MARK: - Manual Authentication
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case .linkMobileDevice(let progressPublisher):
|
case .linkMobileDevice(let progressPublisher):
|
||||||
break
|
presentQRCodeScreen(mode: .linkMobile(progressPublisher))
|
||||||
case .linkDesktopComputer:
|
case .linkDesktopComputer:
|
||||||
break
|
presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService()))
|
||||||
case .dismiss:
|
case .dismiss:
|
||||||
actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
}
|
}
|
||||||
@@ -61,4 +61,27 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentQRCodeScreen(mode: QRCodeLoginScreenMode) {
|
||||||
|
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(mode: mode,
|
||||||
|
canSignInManually: false, // No need to worry about this when linking a device.
|
||||||
|
orientationManager: flowParameters.appMediator.windowManager,
|
||||||
|
appMediator: flowParameters.appMediator))
|
||||||
|
coordinator.actionsPublisher
|
||||||
|
.sink { [weak self] action in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case .signInManually, .signedIn:
|
||||||
|
fatalError("QR linking shouldn't send sign-in actions.")
|
||||||
|
case .dismiss:
|
||||||
|
navigationStackCoordinator.pop()
|
||||||
|
case .requestOIDCAuthorisation(let url):
|
||||||
|
actionsSubject.send(.requestOIDCAuthorisation(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
navigationStackCoordinator.push(coordinator)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13732,11 +13732,6 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol, @unchecked Sen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
|
class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
|
||||||
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
|
|
||||||
get { return underlyingQrLoginProgressPublisher }
|
|
||||||
set(value) { underlyingQrLoginProgressPublisher = value }
|
|
||||||
}
|
|
||||||
var underlyingQrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never>!
|
|
||||||
|
|
||||||
//MARK: - loginWithQRCode
|
//MARK: - loginWithQRCode
|
||||||
|
|
||||||
@@ -13770,13 +13765,13 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
|
|||||||
var loginWithQRCodeDataReceivedData: Data?
|
var loginWithQRCodeDataReceivedData: Data?
|
||||||
var loginWithQRCodeDataReceivedInvocations: [Data] = []
|
var loginWithQRCodeDataReceivedInvocations: [Data] = []
|
||||||
|
|
||||||
var loginWithQRCodeDataUnderlyingReturnValue: Result<UserSessionProtocol, AuthenticationServiceError>!
|
var loginWithQRCodeDataUnderlyingReturnValue: QRLoginProgressPublisher!
|
||||||
var loginWithQRCodeDataReturnValue: Result<UserSessionProtocol, AuthenticationServiceError>! {
|
var loginWithQRCodeDataReturnValue: QRLoginProgressPublisher! {
|
||||||
get {
|
get {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
return loginWithQRCodeDataUnderlyingReturnValue
|
return loginWithQRCodeDataUnderlyingReturnValue
|
||||||
} else {
|
} else {
|
||||||
var returnValue: Result<UserSessionProtocol, AuthenticationServiceError>? = nil
|
var returnValue: QRLoginProgressPublisher? = nil
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
returnValue = loginWithQRCodeDataUnderlyingReturnValue
|
returnValue = loginWithQRCodeDataUnderlyingReturnValue
|
||||||
}
|
}
|
||||||
@@ -13794,16 +13789,16 @@ class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var loginWithQRCodeDataClosure: ((Data) async -> Result<UserSessionProtocol, AuthenticationServiceError>)?
|
var loginWithQRCodeDataClosure: ((Data) -> QRLoginProgressPublisher)?
|
||||||
|
|
||||||
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
|
func loginWithQRCode(data: Data) -> QRLoginProgressPublisher {
|
||||||
loginWithQRCodeDataCallsCount += 1
|
loginWithQRCodeDataCallsCount += 1
|
||||||
loginWithQRCodeDataReceivedData = data
|
loginWithQRCodeDataReceivedData = data
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.loginWithQRCodeDataReceivedInvocations.append(data)
|
self.loginWithQRCodeDataReceivedInvocations.append(data)
|
||||||
}
|
}
|
||||||
if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure {
|
if let loginWithQRCodeDataClosure = loginWithQRCodeDataClosure {
|
||||||
return await loginWithQRCodeDataClosure(data)
|
return loginWithQRCodeDataClosure(data)
|
||||||
} else {
|
} else {
|
||||||
return loginWithQRCodeDataReturnValue
|
return loginWithQRCodeDataReturnValue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,130 +7,179 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
// Helpers to remove ECI headers from QR Code raw data
|
// Helpers to remove ECI headers from QR Code raw data
|
||||||
// https://gist.github.com/PetrusM/267e2ee8c1d8b5dca17eac085afa7d7c
|
// Originally based on https://gist.github.com/PetrusM/267e2ee8c1d8b5dca17eac085afa7d7c
|
||||||
import AVKit
|
import AVKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum AVMetadataBinaryValueError: Error {
|
||||||
|
case unhandledQRSegmentMode(UInt8)
|
||||||
|
case bitError(BitError)
|
||||||
|
case unknown(Error)
|
||||||
|
}
|
||||||
|
|
||||||
extension AVMetadataMachineReadableCodeObject {
|
extension AVMetadataMachineReadableCodeObject {
|
||||||
var binaryValue: Data? {
|
var qrBinaryValue: Data? {
|
||||||
switch type {
|
get throws(AVMetadataBinaryValueError) {
|
||||||
case .qr:
|
guard type == .qr else { return nil }
|
||||||
guard let binaryValueWithProtocol,
|
|
||||||
let symbolVersion = (descriptor as? CIQRCodeDescriptor)?.symbolVersion else {
|
guard let qrCodeDescriptor = descriptor as? CIQRCodeDescriptor else { return nil }
|
||||||
return nil
|
return try Self.removeQRProtocolData(qrCodeDescriptor.errorCorrectedPayload, symbolVersion: qrCodeDescriptor.symbolVersion)
|
||||||
}
|
|
||||||
return Self.removeQrProtocolData(binaryValueWithProtocol, symbolVersion: symbolVersion)
|
|
||||||
case .aztec:
|
|
||||||
guard let string = stringValue
|
|
||||||
else { return nil }
|
|
||||||
return string.data(using: String.Encoding.isoLatin1)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var binaryValueWithProtocol: Data? {
|
static func removeQRProtocolData(_ input: Data, symbolVersion: Int) throws(AVMetadataBinaryValueError) -> Data? {
|
||||||
guard let descriptor else {
|
var bits = input.bits()
|
||||||
return nil
|
var segment: [UInt8]
|
||||||
}
|
var output: [UInt8] = []
|
||||||
switch type {
|
|
||||||
case .qr:
|
repeat {
|
||||||
return (descriptor as? CIQRCodeDescriptor)?.errorCorrectedPayload
|
segment = try takeSegment(&bits, version: symbolVersion)
|
||||||
case .aztec:
|
output.append(contentsOf: segment)
|
||||||
return (descriptor as? CIAztecCodeDescriptor)?.errorCorrectedPayload
|
} while !segment.isEmpty
|
||||||
case .pdf417:
|
|
||||||
return (descriptor as? CIPDF417CodeDescriptor)?.errorCorrectedPayload
|
return Data(output)
|
||||||
case .dataMatrix:
|
|
||||||
return (descriptor as? CIDataMatrixCodeDescriptor)?.errorCorrectedPayload
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func removeQrProtocolData(_ input: Data, symbolVersion: Int) -> Data? {
|
private static func takeSegment(_ input: inout [Bit], version: Int) throws(AVMetadataBinaryValueError) -> [UInt8] {
|
||||||
var halves = input.halfBytes()
|
do {
|
||||||
var batch = takeBatch(&halves, version: symbolVersion)
|
let mode = try input.takeBits(4)
|
||||||
var output = batch
|
|
||||||
while !batch.isEmpty {
|
return switch mode {
|
||||||
batch = takeBatch(&halves, version: symbolVersion)
|
case 0x02: try input.takeAlphanumericSegment(version) // Alphanumeric
|
||||||
output.append(contentsOf: batch)
|
case 0x04: try input.takeBinarySegment(version) // Binary
|
||||||
}
|
case 0x00: [] // End of data
|
||||||
let data = Data(output)
|
default: throw AVMetadataBinaryValueError.unhandledQRSegmentMode(mode)
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func takeBatch(_ input: inout [HalfByte], version: Int) -> [UInt8] {
|
|
||||||
let characterCountLength = version > 9 ? 16 : 8
|
|
||||||
let mode = input.remove(at: 0)
|
|
||||||
var output = [UInt8]()
|
|
||||||
switch mode.value {
|
|
||||||
// If there is not only binary in the QRCode, then cases should be added here.
|
|
||||||
case 0x04: // Binary
|
|
||||||
let charactersCount: UInt16
|
|
||||||
if characterCountLength == 8 {
|
|
||||||
charactersCount = UInt16(input.takeUInt8())
|
|
||||||
} else {
|
|
||||||
charactersCount = UInt16(input.takeUInt16())
|
|
||||||
}
|
}
|
||||||
for _ in 0..<charactersCount {
|
} catch let error as BitError {
|
||||||
output.append(input.takeUInt8())
|
throw .bitError(error)
|
||||||
}
|
} catch let error as AVMetadataBinaryValueError {
|
||||||
return output
|
throw error
|
||||||
case 0x00: // End of data
|
} catch {
|
||||||
return []
|
throw .unknown(error)
|
||||||
default:
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct HalfByte {
|
enum BitError: Error {
|
||||||
|
case moreThan8BitsTaken
|
||||||
|
case moreThan16BitsTaken
|
||||||
|
case unhandledAlphanumericCharacter(UInt8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Bit {
|
||||||
let value: UInt8
|
let value: UInt8
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension [HalfByte] {
|
private extension [Bit] {
|
||||||
mutating func takeUInt8() -> UInt8 {
|
mutating func takeBits(_ count: Int) throws(BitError) -> UInt8 {
|
||||||
let left = remove(at: 0)
|
guard count <= 8 else { throw .moreThan8BitsTaken }
|
||||||
let right = remove(at: 0)
|
|
||||||
return UInt8(left, right)
|
var value: UInt8 = 0
|
||||||
|
for _ in 0..<count {
|
||||||
|
value = (value << 1) | remove(at: 0).value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func takeUInt16() -> UInt16 {
|
mutating func takeBits16(_ count: Int) throws(BitError) -> UInt16 {
|
||||||
let first = remove(at: 0)
|
guard count <= 16 else { throw .moreThan16BitsTaken }
|
||||||
let second = remove(at: 0)
|
|
||||||
let third = remove(at: 0)
|
var value: UInt16 = 0
|
||||||
let fourth = remove(at: 0)
|
for _ in 0..<count {
|
||||||
return UInt16(first, second, third, fourth)
|
value = (value << 1) | UInt16(remove(at: 0).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeUInt8() throws(BitError) -> UInt8 {
|
||||||
|
try takeBits(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeUInt16() throws(BitError) -> UInt16 {
|
||||||
|
try takeBits16(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeBinarySegment(_ version: Int) throws(BitError) -> [UInt8] {
|
||||||
|
let characterCountLength = version > 9 ? 16 : 8
|
||||||
|
let charactersCount = try takeBits16(characterCountLength)
|
||||||
|
|
||||||
|
var output = [UInt8]()
|
||||||
|
for _ in 0..<charactersCount {
|
||||||
|
try output.append(takeUInt8())
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeAlphanumericSegment(_ version: Int) throws(BitError) -> [UInt8] {
|
||||||
|
let characterCountLength = version > 9 ? (version > 26 ? 13 : 11) : 9
|
||||||
|
let charactersCount = try takeBits16(characterCountLength)
|
||||||
|
|
||||||
|
var output = [UInt8]()
|
||||||
|
var charactersRemaining = charactersCount
|
||||||
|
while charactersRemaining > 1 {
|
||||||
|
if count < 11 {
|
||||||
|
// done
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
// read the 11 bits
|
||||||
|
let nextTwoCharacters = try takeBits16(11)
|
||||||
|
// split into the two characters
|
||||||
|
try output.append(Self.alphaToByte(UInt8(nextTwoCharacters / 45)))
|
||||||
|
try output.append(Self.alphaToByte(UInt8(nextTwoCharacters % 45)))
|
||||||
|
charactersRemaining -= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if charactersRemaining == 1 {
|
||||||
|
if count < 6 {
|
||||||
|
// done
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
let nextCharacter = try takeBits(6)
|
||||||
|
try output.append(Self.alphaToByte(nextCharacter))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func alphaToByte(_ input: UInt8) throws(BitError) -> UInt8 {
|
||||||
|
let value: UInt8? = switch input {
|
||||||
|
case 0...9: input + 0x30 // 0-9
|
||||||
|
case 10...35: input - 10 + 0x41 // A-Z
|
||||||
|
case 36: " ".utf8.first
|
||||||
|
case 37: "$".utf8.first
|
||||||
|
case 38: "%".utf8.first
|
||||||
|
case 39: "*".utf8.first
|
||||||
|
case 40: "+".utf8.first
|
||||||
|
case 41: "-".utf8.first
|
||||||
|
case 42: ".".utf8.first
|
||||||
|
case 43: "/".utf8.first
|
||||||
|
case 44: ":".utf8.first
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let value else { throw .unhandledAlphanumericCharacter(input) }
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Data {
|
private extension Data {
|
||||||
func halfBytes() -> [HalfByte] {
|
func bits() -> [Bit] {
|
||||||
var result = [HalfByte]()
|
var result = [Bit]()
|
||||||
forEach { (byte: UInt8) in
|
forEach { (byte: UInt8) in
|
||||||
result.append(contentsOf: byte.halfBytes())
|
result.append(contentsOf: byte.bits())
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension UInt8 {
|
private extension UInt8 {
|
||||||
func halfBytes() -> [HalfByte] {
|
func bits() -> [Bit] {
|
||||||
[HalfByte(value: self >> 4), HalfByte(value: self & 0x0F)]
|
var bits: [Bit] = []
|
||||||
}
|
for i in 0..<8 {
|
||||||
|
let bitValue = (self >> (7 - i)) & 1
|
||||||
init(_ left: HalfByte, _ right: HalfByte) {
|
bits.append(Bit(value: bitValue))
|
||||||
self.init((left.value << 4) + (right.value & 0x0F))
|
}
|
||||||
}
|
return bits
|
||||||
}
|
|
||||||
|
|
||||||
private extension UInt16 {
|
|
||||||
init(_ first: HalfByte, _ second: HalfByte, _ third: HalfByte, _ fourth: HalfByte) {
|
|
||||||
let first = UInt16(first.value) << 12
|
|
||||||
let second = UInt16(second.value) << 8
|
|
||||||
let third = UInt16(third.value) << 4
|
|
||||||
let fourth = UInt16(fourth.value) & 0x0F
|
|
||||||
let result = first + second + third + fourth
|
|
||||||
self.init(result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Combine
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum LinkNewDeviceScreenCoordinatorAction {
|
enum LinkNewDeviceScreenCoordinatorAction {
|
||||||
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
|
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||||
case linkDesktopComputer
|
case linkDesktopComputer
|
||||||
case dismiss
|
case dismiss
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum LinkNewDeviceScreenViewModelAction {
|
enum LinkNewDeviceScreenViewModelAction {
|
||||||
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
|
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||||
case linkDesktopComputer
|
case linkDesktopComputer
|
||||||
case dismiss
|
case dismiss
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
|
|
||||||
let linkNewDeviceService = clientProxy.linkNewDeviceService()
|
let linkNewDeviceService = clientProxy.linkNewDeviceService()
|
||||||
|
|
||||||
let progressPublisher = linkNewDeviceService.generateQRCode()
|
let progressPublisher = linkNewDeviceService.linkMobileDevice()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await progressPublisher.values
|
_ = try await progressPublisher.values
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ import Combine
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct QRCodeLoginScreenCoordinatorParameters {
|
struct QRCodeLoginScreenCoordinatorParameters {
|
||||||
let qrCodeLoginService: QRCodeLoginServiceProtocol
|
let mode: QRCodeLoginScreenMode
|
||||||
let canSignInManually: Bool
|
let canSignInManually: Bool
|
||||||
let orientationManager: OrientationManagerProtocol
|
let orientationManager: OrientationManagerProtocol
|
||||||
let appMediator: AppMediatorProtocol
|
let appMediator: AppMediatorProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRCodeLoginScreenCoordinatorAction {
|
enum QRCodeLoginScreenCoordinatorAction {
|
||||||
case cancel
|
case dismiss
|
||||||
case signInManually
|
case signInManually
|
||||||
case done(userSession: UserSessionProtocol)
|
case signedIn(userSession: UserSessionProtocol)
|
||||||
|
case requestOIDCAuthorisation(URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
|
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
|
||||||
@@ -34,7 +35,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(parameters: QRCodeLoginScreenCoordinatorParameters) {
|
init(parameters: QRCodeLoginScreenCoordinatorParameters) {
|
||||||
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService,
|
viewModel = QRCodeLoginScreenViewModel(mode: parameters.mode,
|
||||||
canSignInManually: parameters.canSignInManually,
|
canSignInManually: parameters.canSignInManually,
|
||||||
appMediator: parameters.appMediator)
|
appMediator: parameters.appMediator)
|
||||||
orientationManager = parameters.orientationManager
|
orientationManager = parameters.orientationManager
|
||||||
@@ -47,11 +48,13 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch action {
|
switch action {
|
||||||
case .signInManually:
|
case .signInManually:
|
||||||
self.actionsSubject.send(.signInManually)
|
actionsSubject.send(.signInManually)
|
||||||
case .cancel:
|
case .dismiss:
|
||||||
self.actionsSubject.send(.cancel)
|
actionsSubject.send(.dismiss)
|
||||||
case .done(let userSession):
|
case .signedIn(let userSession):
|
||||||
self.actionsSubject.send(.done(userSession: userSession))
|
actionsSubject.send(.signedIn(userSession: userSession))
|
||||||
|
case .requestOIDCAuthorisation(let url):
|
||||||
|
actionsSubject.send(.requestOIDCAuthorisation(url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -6,62 +6,89 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import SwiftUI
|
||||||
|
|
||||||
enum QRCodeLoginScreenViewModelAction {
|
enum QRCodeLoginScreenViewModelAction {
|
||||||
case cancel
|
case dismiss
|
||||||
case signInManually
|
case signInManually
|
||||||
case done(userSession: UserSessionProtocol)
|
case signedIn(userSession: UserSessionProtocol)
|
||||||
|
case requestOIDCAuthorisation(URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum QRCodeLoginScreenMode {
|
||||||
|
/// Configures the screen to login this device by scanning a QR code.
|
||||||
|
case login(QRCodeLoginServiceProtocol)
|
||||||
|
/// Configures the screen to link another device by scanning a QR code.
|
||||||
|
case linkDesktop(LinkNewDeviceService)
|
||||||
|
/// Configures the screen to link another device by showing it a QR code.
|
||||||
|
case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QRCodeLoginScreenViewState: BindableState {
|
struct QRCodeLoginScreenViewState: BindableState {
|
||||||
var state: QRCodeLoginState = .initial
|
var state: QRCodeLoginState
|
||||||
/// Whether or not it is possible for the screen to start the manual sign in flow. This was added to avoid
|
/// Whether or not it is possible for the screen to start the manual sign in flow. This was added to avoid
|
||||||
/// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false.
|
/// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false.
|
||||||
let canSignInManually: Bool
|
let canSignInManually: Bool
|
||||||
|
let isPresentedModally: Bool
|
||||||
|
|
||||||
private static let initialStateListItem3AttributedText = {
|
let instructions = QRCodeLoginScreenInstructions()
|
||||||
let boldPlaceholder = "{bold}"
|
|
||||||
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder))
|
|
||||||
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action)
|
|
||||||
boldString.bold()
|
|
||||||
finalString.replace(boldPlaceholder, with: boldString)
|
|
||||||
return finalString
|
|
||||||
}()
|
|
||||||
|
|
||||||
let initialStateListItems = [
|
|
||||||
AttributedString(L10n.screenQrCodeLoginInitialStateItem1(InfoPlistReader.main.productionAppName)),
|
|
||||||
AttributedString(L10n.screenQrCodeLoginInitialStateItem2),
|
|
||||||
initialStateListItem3AttributedText,
|
|
||||||
AttributedString(L10n.screenQrCodeLoginInitialStateItem4)
|
|
||||||
]
|
|
||||||
|
|
||||||
let connectionNotSecureListItems = [
|
|
||||||
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem1),
|
|
||||||
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem2),
|
|
||||||
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem3)
|
|
||||||
]
|
|
||||||
|
|
||||||
var bindings = QRCodeLoginScreenViewStateBindings()
|
var bindings = QRCodeLoginScreenViewStateBindings()
|
||||||
|
|
||||||
|
var shouldDisplayCancelButton: Bool {
|
||||||
|
// TODO: Simplify/validate these assumptions.
|
||||||
|
if isPresentedModally {
|
||||||
|
switch state {
|
||||||
|
case .loginInstructions, .scan, .error(.noCameraPermission): true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch state {
|
||||||
|
case .displayCode, .confirmCode, .scan, .error(.noCameraPermission): true
|
||||||
|
case .loginInstructions, .linkDesktopInstructions, .displayQR, .error: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldDisplayBackButton: Bool {
|
||||||
|
if isPresentedModally {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
switch state {
|
||||||
|
case .loginInstructions, .linkDesktopInstructions, .displayQR: true
|
||||||
|
case .displayCode, .confirmCode, .scan, .error: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QRCodeLoginScreenViewStateBindings {
|
struct QRCodeLoginScreenViewStateBindings {
|
||||||
var qrResult: Data?
|
var qrResult: Data?
|
||||||
|
var checkCodeInput = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRCodeLoginScreenViewAction {
|
enum QRCodeLoginScreenViewAction {
|
||||||
case cancel
|
case dismiss
|
||||||
case startScan
|
case startScan
|
||||||
|
case sendCheckCode
|
||||||
case errorAction(QRCodeErrorView.Action)
|
case errorAction(QRCodeErrorView.Action)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRCodeLoginState: Equatable {
|
enum QRCodeLoginState: Equatable {
|
||||||
/// Initial state where the user is informed how to perform the scan
|
/// Initial state where the user is informed how to login this device by scanning a QR code.
|
||||||
case initial
|
case loginInstructions
|
||||||
/// The camera is scanning
|
/// Initial state where the user is informed how to link another device by scanning it's QR code.
|
||||||
|
case linkDesktopInstructions
|
||||||
|
|
||||||
|
/// The camera is scanning a QR code.
|
||||||
case scan(ScanningState)
|
case scan(ScanningState)
|
||||||
/// Codes are being shown
|
/// Codes are being shown.
|
||||||
case displayCode(QRCodeLoginDisplayCodeState)
|
case displayCode(DisplayCodeState)
|
||||||
|
|
||||||
|
/// Initial state where the user can link another device using the shown QR code.
|
||||||
|
case displayQR(UIImage)
|
||||||
|
/// The user needs to enter the two digit code to confirm the channel is secure
|
||||||
|
case confirmCode(CheckCodeState)
|
||||||
|
|
||||||
/// Any full screen error state
|
/// Any full screen error state
|
||||||
case error(ErrorState)
|
case error(ErrorState)
|
||||||
|
|
||||||
@@ -118,16 +145,30 @@ enum QRCodeLoginState: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum QRCodeLoginDisplayCodeState: Equatable {
|
enum DisplayCodeState: Equatable {
|
||||||
case deviceCode(String)
|
case deviceCode(String)
|
||||||
case verificationCode(String)
|
case verificationCode(String)
|
||||||
|
|
||||||
var code: String {
|
var code: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .deviceCode(let code):
|
case .deviceCode(let code): code
|
||||||
return code
|
case .verificationCode(let code): code
|
||||||
case .verificationCode(let code):
|
}
|
||||||
return code
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CheckCodeState: Equatable {
|
||||||
|
/// The user needs to input the confirmation code.
|
||||||
|
case inputCode(CheckCodeSenderProxy)
|
||||||
|
/// The code supplied by the user didn't pass local validation.
|
||||||
|
case invalidCode
|
||||||
|
/// The code is being sent.
|
||||||
|
case sendingCode
|
||||||
|
|
||||||
|
var isSending: Bool {
|
||||||
|
switch self {
|
||||||
|
case .sendingCode: true
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,11 +186,52 @@ enum QRCodeLoginState: Equatable {
|
|||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
var shouldDisplayCancelButton: Bool {
|
|
||||||
switch self {
|
struct QRCodeLoginScreenInstructions {
|
||||||
case .initial, .scan, .error(.noCameraPermission): true
|
private static let loginItem3 = {
|
||||||
default: false
|
let boldPlaceholder = "{bold}"
|
||||||
}
|
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder))
|
||||||
}
|
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action)
|
||||||
|
boldString.bold()
|
||||||
|
finalString.replace(boldPlaceholder, with: boldString)
|
||||||
|
return finalString
|
||||||
|
}()
|
||||||
|
|
||||||
|
let loginItems = [
|
||||||
|
AttributedString(L10n.screenQrCodeLoginInitialStateItem1(InfoPlistReader.main.productionAppName)), // "Open Element on another device"
|
||||||
|
AttributedString(L10n.screenQrCodeLoginInitialStateItem2), // "Click or tap on your avatar"
|
||||||
|
loginItem3,
|
||||||
|
AttributedString(L10n.screenQrCodeLoginInitialStateItem4)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let linkDesktopItem2 = {
|
||||||
|
let boldPlaceholder = "{bold}"
|
||||||
|
var finalString = AttributedString(L10n.screenLinkNewDeviceMobileStep2(boldPlaceholder))
|
||||||
|
var boldString = AttributedString(L10n.screenLinkNewDeviceMobileStep2Action)
|
||||||
|
boldString.bold()
|
||||||
|
finalString.replace(boldPlaceholder, with: boldString)
|
||||||
|
return finalString
|
||||||
|
}()
|
||||||
|
|
||||||
|
let linkDesktopItems = [
|
||||||
|
AttributedString(L10n.screenLinkNewDeviceDesktopStep1(InfoPlistReader.main.productionAppName)),
|
||||||
|
linkDesktopItem2,
|
||||||
|
AttributedString(L10n.screenLinkNewDeviceDesktopStep3)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let linkMobile = {
|
||||||
|
let boldPlaceholder = "{bold}"
|
||||||
|
var finalString = AttributedString(L10n.screenLinkNewDeviceMobileStep2(boldPlaceholder))
|
||||||
|
var boldString = AttributedString(L10n.screenLinkNewDeviceMobileStep2Action)
|
||||||
|
boldString.bold()
|
||||||
|
finalString.replace(boldPlaceholder, with: boldString)
|
||||||
|
return finalString
|
||||||
|
}()
|
||||||
|
|
||||||
|
let linkMobileItems = [
|
||||||
|
AttributedString(L10n.screenLinkNewDeviceMobileStep1(InfoPlistReader.main.productionAppName)),
|
||||||
|
linkMobile,
|
||||||
|
AttributedString(L10n.screenLinkNewDeviceMobileStep3)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Foundation
|
|||||||
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
|
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
|
||||||
|
|
||||||
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
|
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
|
||||||
private let qrCodeLoginService: QRCodeLoginServiceProtocol
|
private let mode: QRCodeLoginScreenMode
|
||||||
private let appMediator: AppMediatorProtocol
|
private let appMediator: AppMediatorProtocol
|
||||||
|
|
||||||
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
|
||||||
@@ -20,25 +20,53 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
|||||||
actionsSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var scanTask: Task<Void, Never>?
|
private var currentTask: AnyCancellable?
|
||||||
|
|
||||||
init(qrCodeLoginService: QRCodeLoginServiceProtocol,
|
init(mode: QRCodeLoginScreenMode,
|
||||||
canSignInManually: Bool,
|
canSignInManually: Bool,
|
||||||
appMediator: AppMediatorProtocol) {
|
appMediator: AppMediatorProtocol) {
|
||||||
self.qrCodeLoginService = qrCodeLoginService
|
self.mode = mode
|
||||||
self.appMediator = appMediator
|
self.appMediator = appMediator
|
||||||
super.init(initialViewState: QRCodeLoginScreenViewState(canSignInManually: canSignInManually))
|
|
||||||
|
let initialState: QRCodeLoginScreenViewState = switch mode {
|
||||||
|
case .login:
|
||||||
|
.init(state: .loginInstructions, canSignInManually: canSignInManually, isPresentedModally: true)
|
||||||
|
case .linkDesktop:
|
||||||
|
.init(state: .linkDesktopInstructions, canSignInManually: canSignInManually, isPresentedModally: false)
|
||||||
|
case .linkMobile(let progressPublisher):
|
||||||
|
switch progressPublisher.value {
|
||||||
|
case .qrReady(let image):
|
||||||
|
.init(state: .displayQR(image), canSignInManually: canSignInManually, isPresentedModally: false)
|
||||||
|
default:
|
||||||
|
.init(state: .error(.unknown), canSignInManually: canSignInManually, isPresentedModally: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.init(initialViewState: initialState)
|
||||||
setupSubscriptions()
|
setupSubscriptions()
|
||||||
|
|
||||||
|
if case .linkMobile(let progressPublisher) = mode {
|
||||||
|
listenToDisplayQRProgress(progressPublisher: progressPublisher)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
override func process(viewAction: QRCodeLoginScreenViewAction) {
|
override func process(viewAction: QRCodeLoginScreenViewAction) {
|
||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .cancel, .errorAction(.cancel):
|
case .dismiss, .errorAction(.dismiss):
|
||||||
actionsSubject.send(.cancel)
|
actionsSubject.send(.dismiss)
|
||||||
case .startScan, .errorAction(.startScan):
|
case .startScan:
|
||||||
Task { await startScanIfPossible() }
|
Task { await startScanIfPossible() }
|
||||||
|
case .sendCheckCode:
|
||||||
|
Task { await sendCheckCode() }
|
||||||
|
case .errorAction(.startOver):
|
||||||
|
switch mode {
|
||||||
|
case .login:
|
||||||
|
Task { await startScanIfPossible() }
|
||||||
|
case .linkDesktop, .linkMobile:
|
||||||
|
actionsSubject.send(.dismiss)
|
||||||
|
}
|
||||||
case .errorAction(.openSettings):
|
case .errorAction(.openSettings):
|
||||||
appMediator.openAppSettings()
|
appMediator.openAppSettings()
|
||||||
case .errorAction(.signInManually):
|
case .errorAction(.signInManually):
|
||||||
@@ -50,36 +78,22 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
|||||||
|
|
||||||
private func setupSubscriptions() {
|
private func setupSubscriptions() {
|
||||||
context.$viewState
|
context.$viewState
|
||||||
// not using compactMap before remove duplicates because if there is an error, and the same code needs to be rescanned the transition to nil to clean the state would get ignored.
|
// not using compactMap before remove duplicates because if there is an error, and the same
|
||||||
|
// code needs to be rescanned the transition to nil to clean the state would get ignored.
|
||||||
.map(\.bindings.qrResult)
|
.map(\.bindings.qrResult)
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
// this needs to be received on the main actor or the state change for connecting won't work properly
|
// this needs to be received on the main actor or the state change for connecting won't work properly
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] qrData in
|
.sink { [weak self] qrData in
|
||||||
self?.handleScan(qrData: qrData)
|
guard let self else { return }
|
||||||
}
|
switch mode {
|
||||||
.store(in: &cancellables)
|
case .login(let qrCodeLoginService):
|
||||||
|
handleScan(qrData: qrData, loginService: qrCodeLoginService)
|
||||||
qrCodeLoginService.qrLoginProgressPublisher
|
case .linkDesktop(let linkNewDeviceService):
|
||||||
.removeDuplicates()
|
handleScan(qrData: qrData, linkService: linkNewDeviceService)
|
||||||
.receive(on: DispatchQueue.main)
|
case .linkMobile:
|
||||||
.sink { [weak self] progress in
|
fatalError("A code should never be scanned when showing one.")
|
||||||
MXLog.info("QR Login Progress changed to: \(progress)")
|
|
||||||
|
|
||||||
guard let self,
|
|
||||||
// Let's not advance the state if the current state is already invalid
|
|
||||||
!state.state.isError else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch progress {
|
|
||||||
case .establishingSecureChannel(_, let stringCode):
|
|
||||||
state.state = .displayCode(.deviceCode(stringCode))
|
|
||||||
case .waitingForToken(let code):
|
|
||||||
state.state = .displayCode(.verificationCode(code))
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -90,32 +104,160 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
|||||||
state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
|
state.state = await appMediator.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleScan(qrData: Data) {
|
private func handleScan(qrData: Data, loginService: QRCodeLoginServiceProtocol) {
|
||||||
guard scanTask == nil else {
|
guard currentTask == nil else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state.state = .scan(.connecting)
|
state.state = .scan(.connecting)
|
||||||
|
|
||||||
scanTask = Task { [weak self] in
|
MXLog.info("Login scanning QR code")
|
||||||
guard let self else {
|
let progressPublisher = loginService.loginWithQRCode(data: qrData)
|
||||||
return
|
|
||||||
|
currentTask = progressPublisher
|
||||||
|
.removeDuplicates()
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
guard let self else { return }
|
||||||
|
currentTask = nil
|
||||||
|
|
||||||
|
switch completion {
|
||||||
|
case .finished: break
|
||||||
|
case .failure(.qrCodeError(let error)):
|
||||||
|
handleError(error)
|
||||||
|
case .failure:
|
||||||
|
handleError(.unknown)
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] progress in
|
||||||
|
MXLog.info("QR Login Progress changed to: \(progress)")
|
||||||
|
|
||||||
|
guard let self,
|
||||||
|
// Let's not advance the state if the current state is already invalid
|
||||||
|
!state.state.isError else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch progress {
|
||||||
|
case .starting:
|
||||||
|
break // Nothing to do, the state was set above.
|
||||||
|
case .establishingSecureChannel(_, let stringCode):
|
||||||
|
state.state = .displayCode(.deviceCode(stringCode))
|
||||||
|
case .waitingForToken(let code):
|
||||||
|
state.state = .displayCode(.verificationCode(code))
|
||||||
|
case .syncingSecrets:
|
||||||
|
break // Nothing to do.
|
||||||
|
case .signedIn(let session):
|
||||||
|
MXLog.info("QR Login completed")
|
||||||
|
actionsSubject.send(.signedIn(userSession: session))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: when user cancels in UI then the underlying login needs to be cancelled too. It's unclear if we have that exposed in the bindings yet.
|
||||||
|
|
||||||
|
private func handleScan(qrData: Data, linkService: LinkNewDeviceService) {
|
||||||
|
guard currentTask == nil else { return }
|
||||||
|
|
||||||
|
state.state = .scan(.connecting)
|
||||||
|
|
||||||
|
MXLog.info("Link scanning QR code")
|
||||||
|
let progressPublisher = linkService.linkDesktopDevice(with: qrData)
|
||||||
|
|
||||||
|
currentTask = progressPublisher
|
||||||
|
.removeDuplicates()
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] completion in
|
||||||
|
guard let self else { return }
|
||||||
|
currentTask = nil
|
||||||
|
|
||||||
|
if case .failure(let error) = completion {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] progress in
|
||||||
|
MXLog.info("Linking with QR progress changed to: \(progress)")
|
||||||
|
|
||||||
|
guard let self,
|
||||||
|
// Let's not advance the state if the current state is already invalid
|
||||||
|
!state.state.isError else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch progress {
|
||||||
|
case .starting:
|
||||||
|
break // Nothing to do, the state was set above.
|
||||||
|
case .establishingSecureChannel(let checkCodeString):
|
||||||
|
state.state = .displayCode(.deviceCode(checkCodeString))
|
||||||
|
case .waitingForAuthorisation(let url):
|
||||||
|
actionsSubject.send(.requestOIDCAuthorisation(url))
|
||||||
|
case .syncingSecrets:
|
||||||
|
break // Nothing to do.
|
||||||
|
case .done:
|
||||||
|
MXLog.info("Link with QR code completed.")
|
||||||
|
actionsSubject.send(.dismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listenToDisplayQRProgress(progressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher) {
|
||||||
|
state.bindings.qrResult = nil
|
||||||
|
|
||||||
defer {
|
MXLog.info("Link showing QR code.")
|
||||||
scanTask = nil
|
|
||||||
}
|
currentTask = progressPublisher
|
||||||
|
.removeDuplicates()
|
||||||
MXLog.info("Scanning QR code: \(qrData)")
|
.receive(on: DispatchQueue.main)
|
||||||
switch await qrCodeLoginService.loginWithQRCode(data: qrData) {
|
.sink { [weak self] completion in
|
||||||
case let .success(session):
|
guard let self else { return }
|
||||||
MXLog.info("QR Login completed")
|
currentTask = nil
|
||||||
actionsSubject.send(.done(userSession: session))
|
|
||||||
case .failure(.qrCodeError(let error)):
|
if case .failure(let error) = completion {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
case .failure:
|
}
|
||||||
handleError(.unknown)
|
} receiveValue: { [weak self] progress in
|
||||||
|
MXLog.info("Linking with QR progress changed to: \(progress)")
|
||||||
|
|
||||||
|
guard let self,
|
||||||
|
// Let's not advance the state if the current state is already invalid
|
||||||
|
!state.state.isError else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch progress {
|
||||||
|
case .starting, .qrReady:
|
||||||
|
break // Nothing to do, we are already showing the code by the time this method is called.
|
||||||
|
case .qrScanned(let checkCodeSender):
|
||||||
|
state.state = .confirmCode(.inputCode(checkCodeSender))
|
||||||
|
case .waitingForAuthorisation(let url):
|
||||||
|
actionsSubject.send(.requestOIDCAuthorisation(url))
|
||||||
|
case .syncingSecrets:
|
||||||
|
break // Nothing to do.
|
||||||
|
case .done:
|
||||||
|
MXLog.info("Link with QR code completed.")
|
||||||
|
actionsSubject.send(.dismiss)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendCheckCode() async {
|
||||||
|
guard case let .confirmCode(.inputCode(checkCodeSender)) = state.state else {
|
||||||
|
fatalError("Attempting to check code from the wrong state.")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Failed to send check code: \(error)")
|
||||||
|
handleError(.unknown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +270,6 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
|||||||
state.state = .scan(.scanFailed(.notAllowed(scannedProvider: scannedProvider, allowedProviders: allowedProviders)))
|
state.state = .scan(.scanFailed(.notAllowed(scannedProvider: scannedProvider, allowedProviders: allowedProviders)))
|
||||||
case .deviceNotSignedIn:
|
case .deviceNotSignedIn:
|
||||||
state.state = .scan(.scanFailed(.deviceNotSignedIn))
|
state.state = .scan(.scanFailed(.deviceNotSignedIn))
|
||||||
case .deviceAlreadySignedIn:
|
|
||||||
state.state = .error(.deviceAlreadySignedIn)
|
|
||||||
case .cancelled:
|
case .cancelled:
|
||||||
state.state = .error(.cancelled)
|
state.state = .error(.cancelled)
|
||||||
case .connectionInsecure:
|
case .connectionInsecure:
|
||||||
@@ -142,21 +282,32 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
|
|||||||
state.state = .error(.expired)
|
state.state = .error(.expired)
|
||||||
case .deviceNotSupported:
|
case .deviceNotSupported:
|
||||||
state.state = .error(.deviceNotSupported)
|
state.state = .error(.deviceNotSupported)
|
||||||
|
case .deviceAlreadySignedIn:
|
||||||
|
state.state = .error(.deviceAlreadySignedIn)
|
||||||
case .unknown:
|
case .unknown:
|
||||||
state.state = .error(.unknown)
|
state.state = .error(.unknown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only for mocking initial states
|
/// Only for mocking initial states
|
||||||
fileprivate init(state: QRCodeLoginState, canSignInManually: Bool) {
|
fileprivate init(state: QRCodeLoginState, canSignInManually: Bool, isPresentedModally: Bool, checkCodeInput: String) {
|
||||||
qrCodeLoginService = QRCodeLoginServiceMock()
|
mode = .login(QRCodeLoginServiceMock())
|
||||||
appMediator = AppMediatorMock.default
|
appMediator = AppMediatorMock.default
|
||||||
super.init(initialViewState: .init(state: state, canSignInManually: canSignInManually))
|
super.init(initialViewState: .init(state: state,
|
||||||
|
canSignInManually: canSignInManually,
|
||||||
|
isPresentedModally: isPresentedModally,
|
||||||
|
bindings: .init(checkCodeInput: checkCodeInput)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension QRCodeLoginScreenViewModel {
|
extension QRCodeLoginScreenViewModel {
|
||||||
static func mock(state: QRCodeLoginState, canSignInManually: Bool = true) -> QRCodeLoginScreenViewModel {
|
static func mock(state: QRCodeLoginState,
|
||||||
QRCodeLoginScreenViewModel(state: state, canSignInManually: canSignInManually)
|
canSignInManually: Bool = true,
|
||||||
|
isPresentedModally: Bool = true,
|
||||||
|
checkCodeInput: String = "") -> QRCodeLoginScreenViewModel {
|
||||||
|
QRCodeLoginScreenViewModel(state: state,
|
||||||
|
canSignInManually: canSignInManually,
|
||||||
|
isPresentedModally: isPresentedModally,
|
||||||
|
checkCodeInput: checkCodeInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct QRCodeErrorView: View {
|
|||||||
let errorState: QRCodeLoginState.ErrorState
|
let errorState: QRCodeLoginState.ErrorState
|
||||||
let canSignInManually: Bool
|
let canSignInManually: Bool
|
||||||
|
|
||||||
enum Action { case openSettings, startScan, signInManually, cancel }
|
enum Action { case openSettings, startOver, signInManually, dismiss }
|
||||||
let action: (Action) -> Void
|
let action: (Action) -> Void
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
@@ -127,12 +127,12 @@ struct QRCodeErrorView: View {
|
|||||||
.buttonStyle(.compound(.primary))
|
.buttonStyle(.compound(.primary))
|
||||||
case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported:
|
case .connectionNotSecure, .unknown, .expired, .declined, .deviceNotSupported:
|
||||||
Button(L10n.screenQrCodeLoginStartOverButton) {
|
Button(L10n.screenQrCodeLoginStartOverButton) {
|
||||||
action(.startScan)
|
action(.startOver)
|
||||||
}
|
}
|
||||||
.buttonStyle(.compound(.primary))
|
.buttonStyle(.compound(.primary))
|
||||||
case .cancelled:
|
case .cancelled:
|
||||||
Button(L10n.actionTryAgain) {
|
Button(L10n.actionTryAgain) {
|
||||||
action(.startScan)
|
action(.startOver)
|
||||||
}
|
}
|
||||||
.buttonStyle(.compound(.primary))
|
.buttonStyle(.compound(.primary))
|
||||||
case .linkingNotSupported:
|
case .linkingNotSupported:
|
||||||
@@ -145,13 +145,13 @@ struct QRCodeErrorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button(L10n.actionCancel) {
|
Button(L10n.actionCancel) {
|
||||||
action(.cancel)
|
action(.dismiss)
|
||||||
}
|
}
|
||||||
.buttonStyle(.compound(.tertiary))
|
.buttonStyle(.compound(.tertiary))
|
||||||
}
|
}
|
||||||
case .deviceAlreadySignedIn:
|
case .deviceAlreadySignedIn:
|
||||||
Button(L10n.actionContinue) {
|
Button(L10n.actionContinue) {
|
||||||
action(.cancel)
|
action(.dismiss)
|
||||||
}
|
}
|
||||||
.buttonStyle(.compound(.primary))
|
.buttonStyle(.compound(.primary))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import SwiftUI
|
|||||||
|
|
||||||
struct QRCodeLoginScreen: View {
|
struct QRCodeLoginScreen: View {
|
||||||
@ObservedObject var context: QRCodeLoginScreenViewModel.Context
|
@ObservedObject var context: QRCodeLoginScreenViewModel.Context
|
||||||
|
|
||||||
@State private var qrFrame = CGRect.zero
|
@State private var qrFrame = CGRect.zero
|
||||||
|
@FocusState private var checkCodeInputFocus
|
||||||
|
|
||||||
var backgroundStyle: Color {
|
var backgroundStyle: Color {
|
||||||
if case .error = context.viewState.state {
|
if case .error = context.viewState.state {
|
||||||
@@ -22,25 +24,30 @@ struct QRCodeLoginScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
mainContent
|
||||||
mainContent
|
.toolbar { toolbar }
|
||||||
.toolbar { toolbar }
|
.toolbar(.visible, for: .navigationBar)
|
||||||
.toolbar(.visible, for: .navigationBar)
|
.background()
|
||||||
.background()
|
.backgroundStyle(backgroundStyle)
|
||||||
.backgroundStyle(backgroundStyle)
|
.interactiveDismissDisabled()
|
||||||
.interactiveDismissDisabled()
|
.navigationBarBackButtonHidden(!context.viewState.shouldDisplayBackButton)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var mainContent: some View {
|
var mainContent: some View {
|
||||||
switch context.viewState.state {
|
switch context.viewState.state {
|
||||||
case .initial:
|
case .loginInstructions:
|
||||||
initialContent
|
loginInstructionsContent
|
||||||
|
case .linkDesktopInstructions:
|
||||||
|
linkDesktopInstructionsContent
|
||||||
case .scan:
|
case .scan:
|
||||||
qrScanContent
|
qrScannerContent
|
||||||
case .displayCode:
|
case .displayCode:
|
||||||
displayCodeContent
|
displayCodeContent
|
||||||
|
case .displayQR:
|
||||||
|
displayQRContent
|
||||||
|
case .confirmCode:
|
||||||
|
confirmCodeContent
|
||||||
case .error(let errorState):
|
case .error(let errorState):
|
||||||
QRCodeErrorView(errorState: errorState, canSignInManually: context.viewState.canSignInManually) { action in
|
QRCodeErrorView(errorState: errorState, canSignInManually: context.viewState.canSignInManually) { action in
|
||||||
context.send(viewAction: .errorAction(action))
|
context.send(viewAction: .errorAction(action))
|
||||||
@@ -48,7 +55,7 @@ struct QRCodeLoginScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var initialContent: some View {
|
private var loginInstructionsContent: some View {
|
||||||
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
||||||
VStack(alignment: .leading, spacing: 40) {
|
VStack(alignment: .leading, spacing: 40) {
|
||||||
TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName),
|
TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName),
|
||||||
@@ -56,7 +63,7 @@ struct QRCodeLoginScreen: View {
|
|||||||
icon: \.computer,
|
icon: \.computer,
|
||||||
iconStyle: .default)
|
iconStyle: .default)
|
||||||
|
|
||||||
SFNumberedListView(items: context.viewState.initialStateListItems)
|
SFNumberedListView(items: context.viewState.instructions.loginItems)
|
||||||
}
|
}
|
||||||
} bottomContent: {
|
} bottomContent: {
|
||||||
Button(L10n.screenQrCodeLoginInitialStateButtonTitle) {
|
Button(L10n.screenQrCodeLoginInitialStateButtonTitle) {
|
||||||
@@ -66,6 +73,23 @@ struct QRCodeLoginScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var linkDesktopInstructionsContent: some View {
|
||||||
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 40) {
|
||||||
|
TitleAndIcon(title: L10n.screenLinkNewDeviceDesktopTitle(InfoPlistReader.main.productionAppName),
|
||||||
|
icon: \.computer,
|
||||||
|
iconStyle: .default)
|
||||||
|
|
||||||
|
SFNumberedListView(items: context.viewState.instructions.linkDesktopItems)
|
||||||
|
}
|
||||||
|
} bottomContent: {
|
||||||
|
Button(L10n.screenLinkNewDeviceDesktopSubmit) {
|
||||||
|
context.send(viewAction: .startScan)
|
||||||
|
}
|
||||||
|
.buttonStyle(.compound(.primary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var displayCodeContent: some View {
|
private var displayCodeContent: some View {
|
||||||
if case let .displayCode(displayCodeState) = context.viewState.state {
|
if case let .displayCode(displayCodeState) = context.viewState.state {
|
||||||
@@ -92,14 +116,14 @@ struct QRCodeLoginScreen: View {
|
|||||||
}
|
}
|
||||||
} bottomContent: {
|
} bottomContent: {
|
||||||
Button(L10n.actionCancel) {
|
Button(L10n.actionCancel) {
|
||||||
context.send(viewAction: .cancel)
|
context.send(viewAction: .dismiss)
|
||||||
}
|
}
|
||||||
.buttonStyle(.compound(.secondary))
|
.buttonStyle(.compound(.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func displayCodeHeader(state: QRCodeLoginState.QRCodeLoginDisplayCodeState) -> some View {
|
private func displayCodeHeader(state: QRCodeLoginState.DisplayCodeState) -> some View {
|
||||||
switch state {
|
switch state {
|
||||||
case .deviceCode:
|
case .deviceCode:
|
||||||
TitleAndIcon(title: L10n.screenQrCodeLoginDeviceCodeTitle,
|
TitleAndIcon(title: L10n.screenQrCodeLoginDeviceCodeTitle,
|
||||||
@@ -114,7 +138,7 @@ struct QRCodeLoginScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var qrScanContent: some View {
|
private var qrScannerContent: some View {
|
||||||
FullscreenDialog(topPadding: 24) {
|
FullscreenDialog(topPadding: 24) {
|
||||||
VStack(spacing: 40) {
|
VStack(spacing: 40) {
|
||||||
TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle,
|
TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle,
|
||||||
@@ -124,12 +148,12 @@ struct QRCodeLoginScreen: View {
|
|||||||
qrScanner
|
qrScanner
|
||||||
}
|
}
|
||||||
} bottomContent: {
|
} bottomContent: {
|
||||||
qrScanFooter
|
qrScannerFooter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var qrScanFooter: some View {
|
private var qrScannerFooter: some View {
|
||||||
if case let .scan(scanState) = context.viewState.state {
|
if case let .scan(scanState) = context.viewState.state {
|
||||||
switch scanState {
|
switch scanState {
|
||||||
case .connecting:
|
case .connecting:
|
||||||
@@ -183,12 +207,82 @@ struct QRCodeLoginScreen: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var displayQRContent: some View {
|
||||||
|
if case let .displayQR(image) = 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)
|
||||||
|
|
||||||
|
SFNumberedListView(items: context.viewState.instructions.linkMobileItems)
|
||||||
|
}
|
||||||
|
} bottomContent: { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var confirmCodeContent: some View {
|
||||||
|
if case let .confirmCode(confirmCode) = context.viewState.state {
|
||||||
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
TitleAndIcon(title: L10n.screenLinkNewDeviceEnterNumberTitle,
|
||||||
|
subtitle: L10n.screenLinkNewDeviceEnterNumberSubtitle,
|
||||||
|
icon: \.computer,
|
||||||
|
iconStyle: .default)
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text(L10n.screenLinkNewDeviceEnterNumberNotice)
|
||||||
|
.font(.compound.bodyMDSemibold)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.compound.textSecondary)
|
||||||
|
|
||||||
|
PINTextField(pinCode: $context.checkCodeInput, maxLength: 2, size: .medium)
|
||||||
|
.focused($checkCodeInputFocus)
|
||||||
|
.disabled(confirmCode.isSending)
|
||||||
|
|
||||||
|
if case .confirmCode(.invalidCode) = context.viewState.state {
|
||||||
|
Label(L10n.screenLinkNewDeviceEnterNumberErrorNumbersDoNotMatch,
|
||||||
|
icon: \.errorSolid,
|
||||||
|
iconSize: .medium,
|
||||||
|
relativeTo: .compound.bodyMDSemibold)
|
||||||
|
.labelStyle(.custom(spacing: 10))
|
||||||
|
.font(.compound.bodyMDSemibold)
|
||||||
|
.foregroundColor(.compound.textCriticalPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} bottomContent: {
|
||||||
|
if case .inputCode = confirmCode {
|
||||||
|
Button(L10n.actionContinue) {
|
||||||
|
context.send(viewAction: .sendCheckCode)
|
||||||
|
}
|
||||||
|
.buttonStyle(.compound(.primary))
|
||||||
|
.disabled(context.checkCodeInput.count < 2 || confirmCode.isSending)
|
||||||
|
} else {
|
||||||
|
Button(L10n.actionStartOver) {
|
||||||
|
context.send(viewAction: .errorAction(.startOver))
|
||||||
|
}
|
||||||
|
.buttonStyle(.compound(.primary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { checkCodeInputFocus = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
private var toolbar: some ToolbarContent {
|
private var toolbar: some ToolbarContent {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
if context.viewState.state.shouldDisplayCancelButton {
|
if context.viewState.shouldDisplayCancelButton {
|
||||||
Button(L10n.actionCancel) {
|
Button(L10n.actionCancel) {
|
||||||
context.send(viewAction: .cancel)
|
context.send(viewAction: .dismiss)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,15 +310,16 @@ private struct QRScannerViewOverlay: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.stroke(.compound.textPrimary, style: StrokeStyle(lineWidth: 4.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase))
|
.stroke(.compound.textPrimary, style: StrokeStyle(lineWidth: 6.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
|
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
// Initial
|
// Instructions
|
||||||
static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial)
|
static let loginInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .loginInstructions)
|
||||||
|
static let linkInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .linkDesktopInstructions)
|
||||||
|
|
||||||
// Scanning
|
// Scanning
|
||||||
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
|
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
|
||||||
@@ -239,40 +334,57 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
|
|
||||||
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
|
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
|
||||||
|
|
||||||
// Display Code
|
// Showing
|
||||||
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
|
static let showingStateViewModel = {
|
||||||
|
let base64QRCode = GrantLoginWithQrCodeHandlerSDKMock.Configuration().generatedBase64QRCode
|
||||||
|
let image = base64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage()
|
||||||
|
return QRCodeLoginScreenViewModel.mock(state: .displayQR(image))
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Displaying codes
|
||||||
|
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
|
||||||
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
|
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
|
||||||
|
|
||||||
|
static let confirmCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.inputCode(CheckCodeSenderProxy(underlyingSender: CheckCodeSenderSDKMock()))))
|
||||||
|
static let confirmCodeEnteredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.inputCode(CheckCodeSenderProxy(underlyingSender: CheckCodeSenderSDKMock()))), checkCodeInput: "12")
|
||||||
|
static let confirmCodeInvalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.invalidCode))
|
||||||
|
|
||||||
// Errors (no need to test them all QRCodeErrorView covers that).
|
// Errors (no need to test them all QRCodeErrorView covers that).
|
||||||
static let errorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined))
|
static let errorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined))
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
QRCodeLoginScreen(context: initialStateViewModel.context)
|
NavigationStack { QRCodeLoginScreen(context: loginInstructionsStateViewModel.context) }
|
||||||
.previewDisplayName("Initial")
|
.previewDisplayName("Login instructions")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: linkInstructionsStateViewModel.context) }
|
||||||
|
.previewDisplayName("Link instructions")
|
||||||
|
|
||||||
QRCodeLoginScreen(context: scanningStateViewModel.context)
|
NavigationStack { QRCodeLoginScreen(context: scanningStateViewModel.context) }
|
||||||
.previewDisplayName("Scanning")
|
.previewDisplayName("Scanning")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: connectingStateViewModel.context) }
|
||||||
QRCodeLoginScreen(context: connectingStateViewModel.context)
|
|
||||||
.previewDisplayName("Connecting")
|
.previewDisplayName("Connecting")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: invalidStateViewModel.context) }
|
||||||
QRCodeLoginScreen(context: invalidStateViewModel.context)
|
|
||||||
.previewDisplayName("Invalid")
|
.previewDisplayName("Invalid")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: notAllowedStateViewModel.context) }
|
||||||
QRCodeLoginScreen(context: notAllowedStateViewModel.context)
|
|
||||||
.previewDisplayName("Not allowed")
|
.previewDisplayName("Not allowed")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context) }
|
||||||
QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context)
|
|
||||||
.previewDisplayName("Device not signed in")
|
.previewDisplayName("Device not signed in")
|
||||||
|
|
||||||
QRCodeLoginScreen(context: deviceCodeStateViewModel.context)
|
NavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) }
|
||||||
.previewDisplayName("Device code")
|
.previewDisplayName("Showing")
|
||||||
|
|
||||||
QRCodeLoginScreen(context: verificationCodeStateViewModel.context)
|
NavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) }
|
||||||
|
.previewDisplayName("Device code")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: verificationCodeStateViewModel.context) }
|
||||||
.previewDisplayName("Verification code")
|
.previewDisplayName("Verification code")
|
||||||
|
|
||||||
QRCodeLoginScreen(context: errorStateViewModel.context)
|
NavigationStack { QRCodeLoginScreen(context: confirmCodeStateViewModel.context) }
|
||||||
|
.previewDisplayName("Confirm code")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: confirmCodeEnteredStateViewModel.context) }
|
||||||
|
.previewDisplayName("Confirm code entered")
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: confirmCodeInvalidStateViewModel.context) }
|
||||||
|
.previewDisplayName("Confirm code invalid")
|
||||||
|
|
||||||
|
NavigationStack { QRCodeLoginScreen(context: errorStateViewModel.context) }
|
||||||
.previewDisplayName("Error")
|
.previewDisplayName("Error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,16 +41,18 @@ struct QRCodeScannerView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||||
// Check if the metadataObjects array is not nil and it contains at least one object.
|
// Check if the metadataObjects array is not nil and it contains at least one object.
|
||||||
guard metadataObjects.count > 0,
|
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject else {
|
||||||
let metadataObj = metadataObjects[0] as? AVMetadataMachineReadableCodeObject,
|
MXLog.error("Invalid QR scan")
|
||||||
metadataObj.type == AVMetadataObject.ObjectType.qr,
|
|
||||||
let data = metadataObj.binaryValue else {
|
|
||||||
MXLog.info("QRCodeScannerView: invalid qr scan")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scanResult = data
|
do {
|
||||||
MXLog.info("QRCodeScannerView: scanned data")
|
let data = try metadataObject.qrBinaryValue
|
||||||
|
scanResult = data
|
||||||
|
MXLog.info("Scanned data")
|
||||||
|
} catch {
|
||||||
|
MXLog.error("Invalid QR code: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
|||||||
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
|
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
|
||||||
private(set) var flow: AuthenticationFlow
|
private(set) var flow: AuthenticationFlow
|
||||||
|
|
||||||
private let qrLoginProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
|
|
||||||
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
|
|
||||||
qrLoginProgressSubject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(userSessionStore: UserSessionStoreProtocol,
|
init(userSessionStore: UserSessionStoreProtocol,
|
||||||
encryptionKeyProvider: EncryptionKeyProviderProtocol,
|
encryptionKeyProvider: EncryptionKeyProviderProtocol,
|
||||||
clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(),
|
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
|
let qrData: QrCodeData
|
||||||
do {
|
do {
|
||||||
qrData = try QrCodeData.fromBytes(bytes: data)
|
qrData = try QrCodeData.fromBytes(bytes: data)
|
||||||
} catch {
|
} catch {
|
||||||
MXLog.error("QRCode decode error: \(error)")
|
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 {
|
guard let scannedServerName = qrData.serverName() else {
|
||||||
MXLog.error("The QR code is from a device that is not yet signed in.")
|
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) {
|
if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) {
|
||||||
MXLog.error("The scanned device's server is not allowed: \(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
|
let listener = SDKListener { progress in
|
||||||
self?.qrLoginProgressSubject.send(progress)
|
guard let progress = QRLoginProgress(rustProgress: progress) else { return }
|
||||||
|
progressSubject.send(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
Task {
|
||||||
let client = try await makeClient(homeserverAddress: scannedServerName)
|
do {
|
||||||
let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue)
|
let client = try await makeClient(homeserverAddress: scannedServerName)
|
||||||
try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener)
|
let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue)
|
||||||
return await userSession(for: client)
|
try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener)
|
||||||
} catch let error as HumanQrLoginError {
|
|
||||||
MXLog.error("QRCode login error: \(error)")
|
switch await userSession(for: client) {
|
||||||
return .failure(error.serviceError)
|
case .success(let userSession):
|
||||||
} catch RemoteSettingsError.elementProRequired(let serverName) {
|
progressSubject.send(.signedIn(userSession))
|
||||||
return .failure(.elementProRequired(serverName: serverName))
|
case .failure(let error):
|
||||||
} catch {
|
progressSubject.send(completion: .failure(error))
|
||||||
MXLog.error("QRCode login unknown error: \(error)")
|
}
|
||||||
return .failure(.qrCodeError(.unknown))
|
} 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() {
|
func reset() {
|
||||||
|
|||||||
@@ -111,7 +111,58 @@ enum QRCodeLoginError: Error, Equatable {
|
|||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol QRCodeLoginServiceProtocol {
|
protocol QRCodeLoginServiceProtocol {
|
||||||
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> { get }
|
typealias QRLoginProgressPublisher = CurrentValuePublisher<QRLoginProgress, AuthenticationServiceError>
|
||||||
|
func loginWithQRCode(data: Data) -> QRLoginProgressPublisher
|
||||||
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError>
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,25 @@ import MatrixRustSDK
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class LinkNewDeviceService {
|
class LinkNewDeviceService {
|
||||||
typealias GenerateProgressPublisher = CurrentValuePublisher<GenerateProgress, QRCodeLoginError>
|
/// Publishes the progress of linking a new device by showing it a QR code.
|
||||||
typealias ScanProgressPublisher = CurrentValuePublisher<ScanProgress, QRCodeLoginError>
|
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 starting
|
||||||
case qrReady(UIImage)
|
case qrReady(UIImage)
|
||||||
case qrScanned(CheckCodeSenderProtocol)
|
case qrScanned(CheckCodeSenderProxy)
|
||||||
case waitingForAuthorisation(verificationURL: URL)
|
case waitingForAuthorisation(verificationURL: URL)
|
||||||
case syncingSecrets
|
case syncingSecrets
|
||||||
case done
|
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 starting
|
||||||
case establishingSecureChannel(checkCode: UInt8, checkCodeString: String)
|
case establishingSecureChannel(checkCodeString: String)
|
||||||
case waitingForAuthorisation(verificationURL: URL)
|
case waitingForAuthorisation(verificationURL: URL)
|
||||||
case syncingSecrets
|
case syncingSecrets
|
||||||
case done
|
case done
|
||||||
@@ -37,8 +41,9 @@ class LinkNewDeviceService {
|
|||||||
grantLoginHandler = handler
|
grantLoginHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateQRCode() -> GenerateProgressPublisher {
|
/// Links a new device by showing it a QR code.
|
||||||
let progressSubject = CurrentValueSubject<GenerateProgress, QRCodeLoginError>(.starting)
|
func linkMobileDevice() -> LinkMobileProgressPublisher {
|
||||||
|
let progressSubject = CurrentValueSubject<LinkMobileProgress, QRCodeLoginError>(.starting)
|
||||||
let listener = SDKListener {
|
let listener = SDKListener {
|
||||||
do {
|
do {
|
||||||
try progressSubject.send(.init(rustProgress: $0))
|
try progressSubject.send(.init(rustProgress: $0))
|
||||||
@@ -64,8 +69,9 @@ class LinkNewDeviceService {
|
|||||||
return progressSubject.asCurrentValuePublisher()
|
return progressSubject.asCurrentValuePublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanQRCode(_ scannedQRData: Data) -> ScanProgressPublisher {
|
/// Links a new device using a QR code generated by said device.
|
||||||
let progressSubject = CurrentValueSubject<ScanProgress, QRCodeLoginError>(.starting)
|
func linkDesktopDevice(with scannedQRData: Data) -> LinkDesktopProgressPublisher {
|
||||||
|
let progressSubject = CurrentValueSubject<LinkDesktopProgress, QRCodeLoginError>(.starting)
|
||||||
let listener = SDKListener {
|
let listener = SDKListener {
|
||||||
do {
|
do {
|
||||||
try progressSubject.send(.init(rustProgress: $0))
|
try progressSubject.send(.init(rustProgress: $0))
|
||||||
@@ -84,7 +90,8 @@ class LinkNewDeviceService {
|
|||||||
return progressSubject.asCurrentValuePublisher()
|
return progressSubject.asCurrentValuePublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
#warning("Check intent/server name here…")
|
#warning("Check intent/server name??")
|
||||||
|
#warning("Check Element Pro here??")
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -103,7 +110,7 @@ class LinkNewDeviceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible {
|
extension LinkNewDeviceService.LinkMobileProgress: CustomStringConvertible {
|
||||||
enum Error: Swift.Error {
|
enum Error: Swift.Error {
|
||||||
case invalidQRCodeData
|
case invalidQRCodeData
|
||||||
case invalidVerificationURI(String)
|
case invalidVerificationURI(String)
|
||||||
@@ -118,7 +125,7 @@ extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible {
|
|||||||
} else {
|
} else {
|
||||||
throw Error.invalidQRCodeData
|
throw Error.invalidQRCodeData
|
||||||
}
|
}
|
||||||
case .qrScanned(let checkCodeSender): .qrScanned(checkCodeSender)
|
case .qrScanned(let checkCodeSender): .qrScanned(.init(underlyingSender: checkCodeSender))
|
||||||
case .waitingForAuth(let verificationURI):
|
case .waitingForAuth(let verificationURI):
|
||||||
// verificationURI is a String; ASWebAuthenticationSession requires a URL.
|
// verificationURI is a String; ASWebAuthenticationSession requires a URL.
|
||||||
if let url = URL(string: verificationURI) {
|
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) }
|
enum Error: Swift.Error { case invalidVerificationURI(String) }
|
||||||
|
|
||||||
init(rustProgress: GrantQrLoginProgress) throws {
|
init(rustProgress: GrantQrLoginProgress) throws {
|
||||||
self = switch rustProgress {
|
self = switch rustProgress {
|
||||||
case .starting: .starting
|
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):
|
case .waitingForAuth(let verificationURI):
|
||||||
// verificationURI is a String; ASWebAuthenticationSession requires a URL.
|
// verificationURI is a String; ASWebAuthenticationSession requires a URL.
|
||||||
if let url = URL(string: verificationURI) {
|
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) {
|
convenience init?(qrCodeData: Data) {
|
||||||
let qrContext = CIContext()
|
let qrContext = CIContext()
|
||||||
let qrFilter = CIFilter.qrCodeGenerator()
|
let qrFilter = CIFilter.qrCodeGenerator()
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a63a91ecf6e3637ec861f1100ebc97fd66abd3faeb014ae7812060f4400b82dc
|
||||||
|
size 112052
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5143e2e544a6aca9373fa9680e2c3a7fbb28be55a77d9f283b0656d5f0340286
|
||||||
|
size 132923
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5b12167a9d5d01b0f1c40ea742d1e3a6b0b5bfce9b712ab78367abbb0e91294e
|
||||||
|
size 65280
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c4748af8b7a64f4c49253508662a7754727530c91a0fa62ec9d6006d13e84048
|
||||||
|
size 86517
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:17624efed44c446b37934f930dc1be97a6eabff196556707edc5b29d757bed3f
|
||||||
|
size 112327
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f7581d78168eee58b20477e28e45f129bfce099e8671d19f7ed2bb3ba5cf07ce
|
||||||
|
size 133584
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:73423e5a5d8d101f9fdb5204d9f89a3d5881fe2897b8e6c676c2b5cf8ad819bd
|
||||||
|
size 65658
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3efadbacd57c47f8de49a5ffc6437e4bbad98b35455ddb4004238edfc31fa225
|
||||||
|
size 86991
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:75d2836225b86cc489ffb2e97d2b88a5a615497013c52a754e11d6064291efa7
|
||||||
|
size 120243
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:12e674f700020c1b8ec14d1dd281805bb551ffe2cf510fa9966fbd7bd99e02da
|
||||||
|
size 146278
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:52f93b01f90d1287ec889a2416eb0881875276343a9f7cd42cc6041e4410574d
|
||||||
|
size 71887
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5b6b686d6b4f544c143d4dfd71b0c66417de9b7e7bc0f99f5ef66f8bacaff2e9
|
||||||
|
size 97854
|
||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3f2967fe12faab759a15dedd5285ecf29c072fea9de3558834a40481d2afd659
|
oid sha256:2fbe2e5f121dfe92ae934f15e550f66fc752848d88a7cc403c26e7233b9300c6
|
||||||
size 105436
|
size 105657
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b741a5591c5b0ea9371f00f177383aa04e3d23fa44341fdd30a8fb013fa9dac2
|
oid sha256:3f580ac10e8da845e63f237cef93acdbca26ca480c6595a05d1098a1cb07da34
|
||||||
size 109419
|
size 109350
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:131d0c3f463b81f0413c0c517c8b8f5a9cba4c786c77f9dae15cd62c39cc070f
|
oid sha256:f1efeedc569846e8d896de85c20da4876b278671ffcd1a30ae72f8ad10bc1314
|
||||||
size 46233
|
size 46576
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:47ebd411e4cc3a5100e72a0799c0b5f331025073eceaf35ace4063b06dda6e5c
|
oid sha256:a8e19b9a1790a26f7bd76f11a2b5425d3545584613f3e39eca3f109b65722ea4
|
||||||
size 53670
|
size 56081
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6a747e378979ffc936d8d3481ab31a5f5839a1544e736ccfe300aa56f42ffbf2
|
oid sha256:9e3c13b00e7e452c71175cc262983cbf9a500acbcdd4f525d335759e0a0fda9a
|
||||||
size 122713
|
size 122905
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3afd52dd8852df06eaa75d39d5a6c3542caacb852e174cc092d22af7665f554f
|
oid sha256:6b59ab75c3a03ade9121fc4fc0b24f87732d2ea222a99922bf39a57f9bb0175b
|
||||||
size 138264
|
size 138177
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d3da51241718c13a77b60150526f719ea95f3e93bf216cc48ebce119b71aea4b
|
oid sha256:3a2995fbb7b5339fcd062a3f811698697dcdbb264672aabb965181e5fa756529
|
||||||
size 64014
|
size 64409
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3262cbaa505948ad223c1c91e2cbed5c495fa3598a8a5aa1b199988edf17d8f5
|
oid sha256:9e94e59a5157ccc95dff6e528f826c1920d7ee95bd51738e26643ff815f53113
|
||||||
size 81960
|
size 84372
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:16254261843f052920c95d965b3016819e0d865412d5ba6de5f591a71d2c8fc3
|
oid sha256:85fb5855adedb446af1fc92f730a6fe2b4a53f48c2ec8c8028ef36d82e9cb6c3
|
||||||
size 117347
|
size 117537
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:312f8605592eef5075c3da92d2f075b939da391ac1a41fdb0f025d1bc45478de
|
oid sha256:641136b99c2c39ab0ad2f842e43b5b589e15c8ac14c1bd10110535eb648921b4
|
||||||
size 122574
|
size 122486
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:af249b371b990aa1d04a05ae9d9b008e7c5557281eaf22a9275902f909e9af7d
|
oid sha256:4e5b1f5d79e1838bf4b6fee3cbec9e672a5446544ebd1e2c5c5f16126c896e12
|
||||||
size 56989
|
size 57386
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f6fbce453be9a4de2ad06c5b09b549e9cc2f8295082f69245f555838ee290f50
|
oid sha256:809a8b95dc396882887e2eae8c3df421adc962955e6a41cfa893b595a198ddbc
|
||||||
size 69272
|
size 71703
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:19cfafc5a77aec22b3000647fdb3037a97ede0c372ea92ff8b7c13b96d9f522b
|
||||||
|
size 128392
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:63f38ed565d61eae5efeec35e6855393862e60ed0c40a0033fc49cc1a289f1e0
|
||||||
|
size 159424
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4bc9b7331d0c87575afdda93b12f8abc427ce71807d55facd2829c7b0bdd6141
|
||||||
|
size 78957
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:007ae459fcf2deb6bea1438dc0bb70ddef423a86a3727d0a0cbf2d6b03faf4bf
|
||||||
|
size 114834
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9d3f9ac2da64c6074721514cc8b93d4e1cf05240aea28fe690a1a6484f7a9443
|
||||||
|
size 145926
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7817cbafd5804842146c99ee173439148de27f12383d31453e6bcb42efe0710a
|
||||||
|
size 179448
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6a19c30026c415e7e9863fa0981aa2c099af74d3f5989080b5f30dfba1fc2a16
|
||||||
|
size 88935
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:78d88f95b904dde538144d3f4ea90367f7e43e19b67e523344d54f7f7fe5df4c
|
||||||
|
size 128075
|
||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4ed9a41aee39af26d15a6029ca234752bc6ce05c657bc823cf872f6f6f96c6fa
|
oid sha256:a424225295a47e2a36259db2853d3b3614fa692998fe20b5a5ba38907c5c360e
|
||||||
size 122692
|
size 122887
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:18ce471267234a27ac322548545205dbfa765b167280d4315062235f0805c21d
|
oid sha256:fe6acd808e37f633beb64ea2c8adff746ed396c4d9233f26a158bceac35ebde7
|
||||||
size 132827
|
size 132741
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fb8d177b329c8dfa85e33d01790f5086aebe98e12f6743aca82ff20ce8d231a9
|
oid sha256:b4a1f3b1e53caf4161530cb06d814be1107844fe4e50e698407c1f0c081dc8c3
|
||||||
size 62075
|
size 62469
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4b9912afa328dbd1cbc5867e2ce286799c230671d5403371381dc2ed55bdc2e0
|
oid sha256:bb133954fb0bcda46bc45426000e67da92c4f7e4ffe2a2928a789a34b84e7a4b
|
||||||
size 78024
|
size 80418
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:05f9fcaecd47a40388838e9fb89b4ae5d0c334ccc09a1578c724eed0993787fb
|
oid sha256:03ade7d64d525c1f7330fa91e2b02380a9a144ca9260b673048425c9252300b0
|
||||||
size 98081
|
size 98333
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:44835ad6a27b78ade624a218a12df47e89e6737f7ebaae08d2c3ea50aea2afd1
|
oid sha256:465781cf576ef369b7e2e630503952d7847281bb44a14f0126e3c0e79d95ce1f
|
||||||
size 98950
|
size 98918
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9341836edad19787c281a50d70ca70744424cc879d383eece89a36562dda01df
|
oid sha256:d6a437dbbd5255f02ecce3ed2a29d7620257468475c70d7e31f8fc74aa9ea6e4
|
||||||
size 40463
|
size 40815
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:42fc1d64b9a59f7566e06a57352f5ad32af23a5a514d7a2e70682175ea516165
|
oid sha256:02a212502b77bb966c5a0af6fabf319bdac8ac6b81686e992ee12e85b537a254
|
||||||
size 44753
|
size 47131
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0dc2792601fe8379ba95205e54af0feb265b89b98cafcf04050d73c6243caaa7
|
||||||
|
size 243770
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:66501da9f7352a34a1704744357c6df7baa30b8eb9af3c868619183233e7863f
|
||||||
|
size 267320
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e07ab39faf3b92f048cc74c2a1f6e67679e83ed0da443899dfc856c046889399
|
||||||
|
size 177054
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:980d63529998d5801b39d1c205499a9ce9bfa811da5ba36cbe4bf91624e363d1
|
||||||
|
size 202584
|
||||||
@@ -24,7 +24,7 @@ final class AVMetadataMachineReadableCodeObjectExtensionsTest: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let resultData = AVMetadataMachineReadableCodeObject.removeQrProtocolData(data, symbolVersion: symbolVersion) else {
|
guard let resultData = try? AVMetadataMachineReadableCodeObject.removeQRProtocolData(data, symbolVersion: symbolVersion) else {
|
||||||
XCTFail("Could not remove the protocol data")
|
XCTFail("Could not remove the protocol data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import MatrixRustSDK
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
||||||
private var qrProgressSubject: PassthroughSubject<QrLoginProgress, Never>!
|
private var qrProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
|
||||||
private var qrServiceMock: QRCodeLoginServiceMock!
|
private var qrServiceMock: QRCodeLoginServiceMock!
|
||||||
private var appMediatorMock: AppMediatorMock!
|
private var appMediatorMock: AppMediatorMock!
|
||||||
private var viewModel: QRCodeLoginScreenViewModelProtocol!
|
private var viewModel: QRCodeLoginScreenViewModelProtocol!
|
||||||
@@ -25,17 +25,17 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
qrProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
|
qrProgressSubject = .init(.starting)
|
||||||
qrServiceMock = QRCodeLoginServiceMock()
|
qrServiceMock = QRCodeLoginServiceMock()
|
||||||
qrServiceMock.underlyingQrLoginProgressPublisher = qrProgressSubject.eraseToAnyPublisher()
|
qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher()
|
||||||
appMediatorMock = AppMediatorMock.default
|
appMediatorMock = AppMediatorMock.default
|
||||||
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: qrServiceMock,
|
viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock),
|
||||||
canSignInManually: true,
|
canSignInManually: true,
|
||||||
appMediator: appMediatorMock)
|
appMediator: appMediatorMock)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInitialState() {
|
func testInitialState() {
|
||||||
XCTAssertEqual(context.viewState.state, .initial)
|
XCTAssertEqual(context.viewState.state, .loginInstructions)
|
||||||
XCTAssertNil(context.qrResult)
|
XCTAssertNil(context.qrResult)
|
||||||
XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled)
|
XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled)
|
||||||
XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled)
|
XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled)
|
||||||
@@ -44,7 +44,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
func testRequestCameraPermission() async throws {
|
func testRequestCameraPermission() async throws {
|
||||||
appMediatorMock.requestAuthorizationIfNeededReturnValue = false
|
appMediatorMock.requestAuthorizationIfNeededReturnValue = false
|
||||||
XCTAssert(context.viewState.state == .initial)
|
XCTAssert(context.viewState.state == .loginInstructions)
|
||||||
|
|
||||||
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
|
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
|
||||||
state.state == .error(.noCameraPermission)
|
state.state == .error(.noCameraPermission)
|
||||||
@@ -60,15 +60,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testLogin() async throws {
|
func testLogin() async throws {
|
||||||
var isCompleted = false
|
XCTAssert(context.viewState.state == .loginInstructions)
|
||||||
qrServiceMock.loginWithQRCodeDataClosure = { _ in
|
|
||||||
while !isCompleted {
|
|
||||||
await Task.yield()
|
|
||||||
}
|
|
||||||
return .success(UserSessionMock(.init(clientProxy: ClientProxyMock())))
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssert(context.viewState.state == .initial)
|
|
||||||
|
|
||||||
var deferred = deferFulfillment(context.$viewState) { state in
|
var deferred = deferFulfillment(context.$viewState) { state in
|
||||||
state.state == .scan(.scanning)
|
state.state == .scan(.scanning)
|
||||||
@@ -97,14 +89,11 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||||
switch action {
|
switch action {
|
||||||
case .done:
|
case .signedIn: true
|
||||||
return true
|
default: false
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
qrProgressSubject.send(.done)
|
qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
|
||||||
isCompleted = true
|
|
||||||
try await deferredAction.fulfill()
|
try await deferredAction.fulfill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user