Ensure that the current user is the device owner before linking a new device. (#5773)

This commit is contained in:
Doug
2026-06-26 16:24:18 +01:00
committed by GitHub
parent 3f3aa79a48
commit f3da459d16
23 changed files with 558 additions and 18 deletions
@@ -16,6 +16,7 @@ enum LinkNewDeviceScreenCoordinatorAction {
struct LinkNewDeviceScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let appLockService: AppLockServiceProtocol
let orientationManager: OrientationManagerProtocol
}
@@ -31,7 +32,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
}
init(parameters: LinkNewDeviceScreenCoordinatorParameters) {
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy)
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy,
appLockService: parameters.appLockService)
orientationManager = parameters.orientationManager
}
@@ -15,8 +15,14 @@ enum LinkNewDeviceScreenViewModelAction {
struct LinkNewDeviceScreenViewState: BindableState {
nonisolated enum Mode: Equatable {
enum ReadyState: Equatable {
case idle
case verifyingDeviceOwner
case generatingCode
}
case loading
case readyToLink(isGeneratingCode: Bool)
case readyToLink(ReadyState)
case error(QRCodeLoginState.ErrorState)
}
@@ -12,15 +12,19 @@ import SwiftUI
typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction>
class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol {
private enum Device { case mobileDevice, desktopComputer }
private let clientProxy: ClientProxyProtocol
private let appLockService: AppLockServiceProtocol
private let actionsSubject: PassthroughSubject<LinkNewDeviceScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<LinkNewDeviceScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(clientProxy: ClientProxyProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
init(clientProxy: ClientProxyProtocol, appLockService: AppLockServiceProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
self.clientProxy = clientProxy
self.appLockService = appLockService
if let initialState {
super.init(initialViewState: initialState)
@@ -41,9 +45,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch viewAction {
case .linkMobileDevice:
Task { await linkMobileDevice() }
Task { await verifyAndLink(.mobileDevice) }
case .linkDesktopComputer:
actionsSubject.send(.linkDesktopComputer)
Task { await verifyAndLink(.desktopComputer) }
case .errorAction(let action):
handleErrorAction(action)
case .dismiss:
@@ -55,14 +59,41 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
private func checkQRCodeLoginSupport() async {
if await clientProxy.isLoginWithQRCodeSupported {
state.mode = .readyToLink(isGeneratingCode: false)
state.mode = .readyToLink(.idle)
} else {
state.mode = .error(.notSupported)
}
}
private func verifyAndLink(_ device: Device) async {
state.mode = .readyToLink(.verifyingDeviceOwner)
switch await appLockService.verifyDeviceOwner(reason: L10n.screenLinkNewDeviceAuthenticationReasonIos) {
case .verified, .unavailable:
break
case .appLockPINRequired:
// Follow-up PR: present the App Lock PIN screen to verify the device owner.
return
case .cancelled, .unverified:
state.mode = .readyToLink(.idle)
return
case .error:
// TODO: Failure UX is pending product confirmation; using the generic error state for now.
state.mode = .error(.unknown)
return
}
switch device {
case .mobileDevice:
await linkMobileDevice() // Automatically sets the state.
case .desktopComputer:
actionsSubject.send(.linkDesktopComputer)
state.mode = .readyToLink(.idle)
}
}
private func linkMobileDevice() async {
state.mode = .readyToLink(isGeneratingCode: true)
state.mode = .readyToLink(.generatingCode)
let linkNewDeviceService = clientProxy.linkNewDeviceService()
@@ -77,7 +108,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
}
actionsSubject.send(.linkMobileDevice(progressPublisher))
state.mode = .readyToLink(isGeneratingCode: false)
state.mode = .readyToLink(.idle)
} catch {
// This is hard to share a mapping from the QRCodeLoginError with the
// QRCodeLoginScreen given that some of those are scan errors…
@@ -89,7 +120,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch action {
case .startOver:
// Reset to ready state to allow trying again.
state.mode = .readyToLink(isGeneratingCode: false)
state.mode = .readyToLink(.idle)
case .openSettings, .signInManually:
MXLog.error("Unexpected error action: \(action)")
actionsSubject.send(.dismiss)
@@ -59,7 +59,8 @@ struct LinkNewDeviceScreen: View {
}
.buttonStyle(.compound(.primary))
.disabled(true)
case .readyToLink(let isGeneratingCode):
case .readyToLink(let linkState):
let isGeneratingCode = linkState == .generatingCode
VStack(spacing: 16) {
Button { context.send(viewAction: .linkMobileDevice) } label: {
Label {
@@ -84,7 +85,7 @@ struct LinkNewDeviceScreen: View {
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
}
}
.disabled(isGeneratingCode)
.disabled(linkState != .idle)
case .error:
EmptyView() // Not reachable.
}
@@ -103,8 +104,9 @@ struct LinkNewDeviceScreen: View {
// MARK: - Previews
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false))
static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true))
static let viewModel = makeViewModel(mode: .readyToLink(.idle))
static let verifyingViewModel = makeViewModel(mode: .readyToLink(.verifyingDeviceOwner))
static let generatingViewModel = makeViewModel(mode: .readyToLink(.generatingCode))
static let loadingViewModel = makeViewModel(mode: .loading)
static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported))
static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown))
@@ -114,13 +116,19 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
LinkNewDeviceScreen(context: viewModel.context)
}
.previewDisplayName("Ready")
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(isGeneratingCode: false) })
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.idle) })
ElementNavigationStack {
LinkNewDeviceScreen(context: verifyingViewModel.context)
}
.previewDisplayName("Verifying")
.snapshotPreferences(expect: verifyingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.verifyingDeviceOwner) })
ElementNavigationStack {
LinkNewDeviceScreen(context: generatingViewModel.context)
}
.previewDisplayName("Generating")
.snapshotPreferences(expect: generatingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(isGeneratingCode: true) })
.snapshotPreferences(expect: generatingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.generatingCode) })
ElementNavigationStack {
LinkNewDeviceScreen(context: loadingViewModel.context)
@@ -142,6 +150,7 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel {
LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()),
appLockService: AppLockServiceMock.mock(),
initialState: .init(mode: mode,
showLinkDesktopComputerButton: true))
}