Add a service and flow coordinator for the LinkNewDevice feature. (#4859)

* Add a LinkNewDeviceService that exposes the SDK's grant QR code login methods.

* Add a flow coordinator for linking a new device.

Changes the presentation too.
This commit is contained in:
Doug
2025-12-15 14:44:26 +00:00
committed by GitHub
parent 27ca265991
commit 93dfc84a57
38 changed files with 536 additions and 73 deletions
@@ -0,0 +1,64 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
enum LinkNewDeviceFlowCoordinatorAction {
case requestOIDCAuthorisation(URL)
case dismiss
}
class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator
private let flowParameters: CommonFlowParameters
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<LinkNewDeviceFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<LinkNewDeviceFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(navigationStackCoordinator: NavigationStackCoordinator,
flowParameters: CommonFlowParameters) {
self.navigationStackCoordinator = navigationStackCoordinator
self.flowParameters = flowParameters
}
func start(animated: Bool) {
presentLinkNewDeviceScreen()
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
fatalError()
}
func clearRoute(animated: Bool) {
fatalError()
}
private func presentLinkNewDeviceScreen() {
let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy))
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .linkMobileDevice(let progressPublisher):
break
case .linkDesktopComputer:
break
case .dismiss:
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(coordinator)
}
}
@@ -28,6 +28,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
// periphery:ignore - retaining purpose
private var encryptionSettingsFlowCoordinator: EncryptionSettingsFlowCoordinator?
// periphery:ignore - retaining purpose
private var linkNewDeviceFlowCoordinator: LinkNewDeviceFlowCoordinator?
private var cancellables = Set<AnyCancellable>()
@@ -84,7 +86,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
case .userDetails:
presentUserDetailsEditScreen()
case .linkNewDevice:
presentLinkNewDeviceScreen()
startLinkNewDeviceFlow()
case let .manageAccount(url):
presentAccountManagementURL(url)
case .analytics:
@@ -169,6 +171,31 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.push(coordinator)
}
private func startLinkNewDeviceFlow() {
let stackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator,
flowParameters: flowParameters)
flowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .requestOIDCAuthorisation(let url):
presentAccountManagementURL(url)
}
}
.store(in: &cancellables)
linkNewDeviceFlowCoordinator = flowCoordinator
flowCoordinator.start()
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
self?.linkNewDeviceFlowCoordinator = nil
}
}
private func presentAnalyticsScreen() {
let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: flowParameters.appSettings,
analytics: flowParameters.analytics))
@@ -259,9 +286,9 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.push(coordinator)
}
// MARK: OIDC Account Management
private var accountSettingsPresenter: OIDCAccountSettingsPresenter?
private func presentAccountManagementURL(_ url: URL) {
// Note to anyone in the future if you come back here to make this open in Safari instead of a WAS.
@@ -271,27 +298,4 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
appSettings: flowParameters.appSettings)
accountSettingsPresenter?.start()
}
// MARK: - Link New Device
private func presentLinkNewDeviceScreen() {
let parameters = LinkNewDeviceScreenCoordinatorParameters(clientProxy: flowParameters.userSession.clientProxy)
let coordinator = LinkNewDeviceScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .linkMobileDevice:
break
case .linkDesktopComputer:
break
case .dismiss:
navigationStackCoordinator.pop()
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator)
}
}
@@ -4019,6 +4019,70 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return removeUserAvatarReturnValue
}
}
//MARK: - linkNewDeviceService
var linkNewDeviceServiceUnderlyingCallsCount = 0
var linkNewDeviceServiceCallsCount: Int {
get {
if Thread.isMainThread {
return linkNewDeviceServiceUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = linkNewDeviceServiceUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
linkNewDeviceServiceUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
linkNewDeviceServiceUnderlyingCallsCount = newValue
}
}
}
}
var linkNewDeviceServiceCalled: Bool {
return linkNewDeviceServiceCallsCount > 0
}
var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceService!
var linkNewDeviceServiceReturnValue: LinkNewDeviceService! {
get {
if Thread.isMainThread {
return linkNewDeviceServiceUnderlyingReturnValue
} else {
var returnValue: LinkNewDeviceService? = nil
DispatchQueue.main.sync {
returnValue = linkNewDeviceServiceUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
linkNewDeviceServiceUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
linkNewDeviceServiceUnderlyingReturnValue = newValue
}
}
}
}
var linkNewDeviceServiceClosure: (() -> LinkNewDeviceService)?
func linkNewDeviceService() -> LinkNewDeviceService {
linkNewDeviceServiceCallsCount += 1
if let linkNewDeviceServiceClosure = linkNewDeviceServiceClosure {
return linkNewDeviceServiceClosure()
} else {
return linkNewDeviceServiceReturnValue
}
}
//MARK: - deactivateAccount
var deactivateAccountPasswordEraseDataUnderlyingCallsCount = 0
@@ -0,0 +1,33 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
extension GrantLoginWithQrCodeHandlerSDKMock {
struct Configuration {
var generateDelay: Duration = .seconds(0)
var generatedBase64QRCode = """
TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0
cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns
aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo
dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv
"""
}
convenience init(_ configuration: Configuration) {
self.init()
generateProgressListenerClosure = { listener in
Task {
try await Task.sleep(for: configuration.generateDelay)
let bytes = Data(base64Encoded: configuration.generatedBase64QRCode) ?? Data()
try listener.onUpdate(state: .qrReady(qrCode: .fromBytes(bytes: bytes)))
}
}
}
}
@@ -45,6 +45,7 @@ enum A11yIdentifiers {
static let roomPollsHistoryScreen = RoomPollsHistoryScreen()
static let manageRoomMemberSheet = ManageRoomMemberSheet()
static let spaceListScreen = SpaceListScreen()
static let linkNewDeviceScreen = LinkNewDeviceScreen()
struct AlertInfo {
let primaryButton = "alert_info-primary_button"
@@ -314,4 +315,10 @@ enum A11yIdentifiers {
"\(roomNamePrefix):\(name)"
}
}
struct LinkNewDeviceScreen {
let cancel = "link_new_device_screen-cancel"
let mobileDevice = "link_new_device_screen-mobile_device"
let desktopComputer = "link_new_device_screen-desktop_computer"
}
}
+8
View File
@@ -29,6 +29,14 @@ extension SDKListener: QrLoginProgressListener where T == QrLoginProgress {
func onUpdate(state: QrLoginProgress) { onUpdateClosure(state) }
}
extension SDKListener: GrantQrLoginProgressListener where T == GrantQrLoginProgress {
func onUpdate(state: GrantQrLoginProgress) { onUpdateClosure(state) }
}
extension SDKListener: GrantGeneratedQrLoginProgressListener where T == GrantGeneratedQrLoginProgress {
func onUpdate(state: GrantGeneratedQrLoginProgress) { onUpdateClosure(state) }
}
// MARK: ClientProxy
extension SDKListener: MediaPreviewConfigListener where T == MediaPreviewConfig? {
@@ -9,7 +9,7 @@ import Combine
import SwiftUI
enum LinkNewDeviceScreenCoordinatorAction {
case linkMobileDevice
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
case linkDesktopComputer
case dismiss
}
@@ -38,8 +38,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .linkMobileDevice:
actionsSubject.send(.linkMobileDevice)
case .linkMobileDevice(let progressPublisher):
actionsSubject.send(.linkMobileDevice(progressPublisher))
case .linkDesktopComputer:
actionsSubject.send(.linkDesktopComputer)
case .dismiss:
@@ -8,7 +8,7 @@
import Foundation
enum LinkNewDeviceScreenViewModelAction {
case linkMobileDevice
case linkMobileDevice(LinkNewDeviceService.GenerateProgressPublisher)
case linkDesktopComputer
case dismiss
}
@@ -6,6 +6,7 @@
//
import Combine
import MatrixRustSDK
import SwiftUI
typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction>
@@ -33,7 +34,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch viewAction {
case .linkMobileDevice:
linkMobileDevice()
Task { await linkMobileDevice() }
case .linkDesktopComputer:
actionsSubject.send(.linkDesktopComputer)
case .dismiss:
@@ -51,11 +52,27 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
}
}
private func linkMobileDevice() {
private func linkMobileDevice() async {
state.mode = .readyToLink(isGeneratingCode: true)
// TODO: Generate a QR code.
let linkNewDeviceService = clientProxy.linkNewDeviceService()
actionsSubject.send(.linkMobileDevice)
let progressPublisher = linkNewDeviceService.generateQRCode()
do {
_ = try await progressPublisher.values
.first { progress in
switch progress {
case .qrReady: true
default: false
}
}
actionsSubject.send(.linkMobileDevice(progressPublisher))
state.mode = .readyToLink(isGeneratingCode: false)
} catch {
#warning("Needs some form of re-usable error handling, will handle with the next screen.")
state.mode = .notSupported
}
}
}
@@ -21,6 +21,7 @@ struct LinkNewDeviceScreen: View {
.backgroundStyle(.compound.bgSubtleSecondary)
.navigationTitle(L10n.commonLinkNewDevice)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
@ViewBuilder
@@ -86,11 +87,13 @@ struct LinkNewDeviceScreen: View {
}
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.mobileDevice)
Button { context.send(viewAction: .linkDesktopComputer) } label: {
Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
}
.disabled(isGeneratingCode)
case .notSupported:
@@ -100,6 +103,15 @@ struct LinkNewDeviceScreen: View {
.buttonStyle(.compound(.primary))
}
}
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .dismiss)
}
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.cancel)
}
}
}
// MARK: - Previews
@@ -148,6 +160,7 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
return false
}
}
clientProxy.linkNewDeviceServiceReturnValue = .init(handler: GrantLoginWithQrCodeHandlerSDKMock(.init(generateDelay: .seconds(20))))
let viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy)
@@ -140,7 +140,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
state.state = .error(.expired)
case .deviceNotSupported:
state.state = .error(.deviceNotSupported)
case .unknown:
case .deviceAlreadySignedIn, .unknown:
state.state = .error(.unknown)
}
}
@@ -105,6 +105,7 @@ enum QRCodeLoginError: Error, Equatable {
case expired
case deviceNotSupported
case deviceNotSignedIn
case deviceAlreadySignedIn
case unknown
}
@@ -0,0 +1,148 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import MatrixRustSDK
class LinkNewDeviceService {
typealias GenerateProgressPublisher = CurrentValuePublisher<GenerateProgress, QRCodeLoginError>
typealias ScanProgressPublisher = CurrentValuePublisher<ScanProgress, QRCodeLoginError>
enum GenerateProgress {
case starting
case qrReady(QrCodeData)
case qrScanned(CheckCodeSenderProtocol)
case waitingForAuthorisation(verificationURI: String)
case syncingSecrets
case done
}
enum ScanProgress {
case starting
case establishingSecureChannel(checkCode: UInt8, checkCodeString: String)
case waitingForAuth(verificationURI: String)
case syncingSecrets
case done
}
private let grantLoginHandler: GrantLoginWithQrCodeHandlerProtocol
init(handler: GrantLoginWithQrCodeHandlerProtocol) {
grantLoginHandler = handler
}
func generateQRCode() -> GenerateProgressPublisher {
let progressSubject = CurrentValueSubject<GenerateProgress, QRCodeLoginError>(.starting)
let listener = SDKListener { progressSubject.send(.init(rustProgress: $0)) }
Task {
do {
// TODO: we need a way to cancel the in progress grant if the user hit the cancel button
try await grantLoginHandler.generate(progressListener: listener) // The success state is handled by the listener.
} catch let error as HumanQrGrantLoginError {
MXLog.error("QR code reciprocate error: \(error)")
progressSubject.send(completion: .failure(.init(rustError: error)))
} catch {
MXLog.error("QR code reciprocate unknown error: \(error)")
progressSubject.send(completion: .failure(.unknown))
}
}
return progressSubject.asCurrentValuePublisher()
}
func scanQRCode(_ scannedQRData: Data) -> ScanProgressPublisher {
let progressSubject = CurrentValueSubject<ScanProgress, QRCodeLoginError>(.starting)
let listener = SDKListener { progressSubject.send(.init(rustProgress: $0)) }
let qrCodeData: QrCodeData
do {
qrCodeData = try QrCodeData.fromBytes(bytes: scannedQRData)
} catch {
MXLog.error("QR code decode error: \(error)")
progressSubject.send(completion: .failure(.invalidQRCode))
return progressSubject.asCurrentValuePublisher()
}
#warning("Check intent/server name here…")
Task {
do {
// TODO: it would be nice to be able to cancel the grant at the SDK level if the user hits the cancel button
try await grantLoginHandler.scan(qrCodeData: qrCodeData, progressListener: listener) // The success state is handled by the listener.
} catch let error as HumanQrGrantLoginError {
MXLog.error("QR code reciprocate error: \(error)")
progressSubject.send(completion: .failure(.init(rustError: error)))
} catch {
MXLog.error("QR code reciprocate unknown error: \(error)")
progressSubject.send(completion: .failure(.unknown))
}
}
return progressSubject.asCurrentValuePublisher()
}
}
extension LinkNewDeviceService.GenerateProgress: CustomStringConvertible {
init(rustProgress: GrantGeneratedQrLoginProgress) {
self = switch rustProgress {
case .starting: .starting
case .qrReady(let qrCode): .qrReady(qrCode)
case .qrScanned(let checkCodeSender): .qrScanned(checkCodeSender)
case .waitingForAuth(let verificationUri): .waitingForAuthorisation(verificationURI: verificationUri)
case .syncingSecrets: .syncingSecrets
case .done: .done
}
}
var description: String {
switch self {
case .starting: "starting"
case .qrReady: "qrReady"
case .qrScanned: "qrScanned"
case .waitingForAuthorisation: "waitingForAuthorisation"
case .syncingSecrets: "syncingSecrets"
case .done: "done"
}
}
}
extension LinkNewDeviceService.ScanProgress: CustomStringConvertible {
init(rustProgress: GrantQrLoginProgress) {
self = switch rustProgress {
case .starting: .starting
case .establishingSecureChannel(let checkCode, let checkCodeString): .establishingSecureChannel(checkCode: checkCode, checkCodeString: checkCodeString)
case .waitingForAuth(let verificationUri): .waitingForAuth(verificationURI: verificationUri)
case .syncingSecrets: .syncingSecrets
case .done: .done
}
}
var description: String {
switch self {
case .starting: "starting"
case .establishingSecureChannel: "establishingSecureChannel"
case .waitingForAuth: "waitingForAuth"
case .syncingSecrets: "syncingSecrets"
case .done: "done"
}
}
}
private extension QRCodeLoginError {
init(rustError: HumanQrGrantLoginError) {
self = switch rustError {
case .InvalidCheckCode:
.connectionInsecure
case .UnsupportedProtocol:
.linkingNotSupported
case .Unknown, .NotFound, .MissingSecretsBackup, .DeviceIdAlreadyInUse, .UnableToCreateDevice:
.unknown
}
}
}
@@ -715,13 +715,9 @@ class ClientProxy: ClientProxyProtocol {
return .failure(.sdkError(error))
}
}
func logout() async {
do {
try await client.logout()
} catch {
MXLog.error("Failed logging out with error: \(error)")
}
func linkNewDeviceService() -> LinkNewDeviceService {
LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler())
}
func deactivateAccount(password: String?, eraseData: Bool) async -> Result<Void, ClientProxyError> {
@@ -734,6 +730,14 @@ class ClientProxy: ClientProxyProtocol {
}
}
func logout() async {
do {
try await client.logout()
} catch {
MXLog.error("Failed logging out with error: \(error)")
}
}
func setPusher(with configuration: PusherConfiguration) async throws {
try await client.setPusher(identifiers: configuration.identifiers,
kind: configuration.kind,
@@ -194,7 +194,9 @@ protocol ClientProxyProtocol: AnyObject {
func setUserAvatar(media: MediaInfo) async -> Result<Void, ClientProxyError>
func removeUserAvatar() async -> Result<Void, ClientProxyError>
func linkNewDeviceService() -> LinkNewDeviceService
func deactivateAccount(password: String?, eraseData: Bool) async -> Result<Void, ClientProxyError>
func logout() async
@@ -734,6 +734,40 @@ class MockScreen: Identifiable {
coordinator.start()
return navigationStackCoordinator
case .linkNewDevice:
let navigationStackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init()),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
notificationManager: NotificationManagerMock(),
stateMachineFactory: StateMachineFactory()))
flowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationRootCoordinator.setSheetCoordinator(nil)
case .requestOIDCAuthorisation:
break
}
}
.store(in: &cancellables)
retainedState.append(flowCoordinator)
flowCoordinator.start()
// Use a sheet on top the the placeholder so we can test the dismissal.
navigationRootCoordinator.setSheetCoordinator(navigationStackCoordinator)
return PlaceholderScreenCoordinator(hideBrandChrome: false)
case .autoUpdatingTimeline:
let appSettings: AppSettings = ServiceLocator.shared.settings
appSettings.hasRunIdentityConfirmationOnboarding = true
@@ -25,6 +25,7 @@ enum UITestsScreenIdentifier: String {
case encryptionSettings
case encryptionSettingsOutOfSync
case encryptionReset
case linkNewDevice
case roomLayoutBottom
case roomLayoutMiddle
case roomLayoutTop