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
+4
View File
@@ -313,6 +313,7 @@
31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; }; 31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; };
32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; }; 32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; };
32F47002A331817F0E6BD7EB /* RoomMembershipDetailsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1434D5169F0EE319E226DA7F /* RoomMembershipDetailsProxyProtocol.swift */; }; 32F47002A331817F0E6BD7EB /* RoomMembershipDetailsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1434D5169F0EE319E226DA7F /* RoomMembershipDetailsProxyProtocol.swift */; };
33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */; };
339BC18777912E1989F2F17D /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584A61D9C459FAFEF038A7C0 /* Section.swift */; }; 339BC18777912E1989F2F17D /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584A61D9C459FAFEF038A7C0 /* Section.swift */; };
33BA0964A308D2286B39976D /* LabsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */; }; 33BA0964A308D2286B39976D /* LabsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */; };
33CAC1226DFB8B5D8447D286 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; }; 33CAC1226DFB8B5D8447D286 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; };
@@ -2917,6 +2918,7 @@
E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = "<group>"; }; E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = "<group>"; };
E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = "<group>"; }; E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreenViewModelTests.swift; sourceTree = "<group>"; };
E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreen.swift; sourceTree = "<group>"; }; E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreen.swift; sourceTree = "<group>"; };
E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; }; E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallNotificationRoomTimelineItem.swift; sourceTree = "<group>"; }; E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallNotificationRoomTimelineItem.swift; sourceTree = "<group>"; };
@@ -4982,6 +4984,7 @@
DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */, DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */,
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */, FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */,
C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */, C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */,
E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */,
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */, C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */,
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */, C070FD43DC6BF4E50217965A /* LocalizationTests.swift */,
EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */, EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */,
@@ -7955,6 +7958,7 @@
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */, EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */,
BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */, BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */,
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */, CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */,
33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */,
CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */, CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */,
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */, 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */,
F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */, F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */,
@@ -932,6 +932,7 @@
"screen_leave_space_title" = "Leave %1$@?"; "screen_leave_space_title" = "Leave %1$@?";
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@"; "screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
"screen_leave_space_title_last_owner" = "Transfer ownership"; "screen_leave_space_title_last_owner" = "Transfer ownership";
"screen_link_new_device_authentication_reason_ios" = "Confirm it's you before linking a new device.";
"screen_link_new_device_desktop_scanning_title" = "Scan the QR code"; "screen_link_new_device_desktop_scanning_title" = "Scan the QR code";
"screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer"; "screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer";
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device"; "screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
@@ -1,5 +1,5 @@
"NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera."; "NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera.";
"NSFaceIDUsageDescription" = "Face ID is used to access your app."; "NSFaceIDUsageDescription" = "Face ID is used to protect access to your account.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "To share your live location, Element X needs location access when the app is in the background."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "To share your live location, Element X needs location access when the app is in the background.";
"NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location."; "NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location.";
"NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone."; "NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone.";
@@ -932,6 +932,7 @@
"screen_leave_space_title" = "Leave %1$@?"; "screen_leave_space_title" = "Leave %1$@?";
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@"; "screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
"screen_leave_space_title_last_owner" = "Transfer ownership"; "screen_leave_space_title_last_owner" = "Transfer ownership";
"screen_link_new_device_authentication_reason_ios" = "Confirm it's you before linking a new device.";
"screen_link_new_device_desktop_scanning_title" = "Scan the QR code"; "screen_link_new_device_desktop_scanning_title" = "Scan the QR code";
"screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer"; "screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer";
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device"; "screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
@@ -15,6 +15,7 @@ enum LinkNewDeviceFlowCoordinatorAction {
class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol { class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator private let navigationStackCoordinator: NavigationStackCoordinator
private let appLockService: AppLockServiceProtocol
private let flowParameters: CommonFlowParameters private let flowParameters: CommonFlowParameters
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -25,8 +26,10 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
} }
init(navigationStackCoordinator: NavigationStackCoordinator, init(navigationStackCoordinator: NavigationStackCoordinator,
appLockService: AppLockServiceProtocol,
flowParameters: CommonFlowParameters) { flowParameters: CommonFlowParameters) {
self.navigationStackCoordinator = navigationStackCoordinator self.navigationStackCoordinator = navigationStackCoordinator
self.appLockService = appLockService
self.flowParameters = flowParameters self.flowParameters = flowParameters
} }
@@ -44,6 +47,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
private func presentLinkNewDeviceScreen() { private func presentLinkNewDeviceScreen() {
let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy, let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy,
appLockService: appLockService,
orientationManager: flowParameters.appMediator.windowManager)) orientationManager: flowParameters.appMediator.windowManager))
coordinator.actionsPublisher coordinator.actionsPublisher
.sink { [weak self] action in .sink { [weak self] action in
@@ -181,6 +181,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private func startLinkNewDeviceFlow() { private func startLinkNewDeviceFlow() {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator, let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator,
appLockService: appLockService,
flowParameters: flowParameters) flowParameters: flowParameters)
flowCoordinator.actionsPublisher flowCoordinator.actionsPublisher
.sink { [weak self] action in .sink { [weak self] action in
+2
View File
@@ -2170,6 +2170,8 @@ internal nonisolated enum L10n {
} }
/// Transfer ownership /// Transfer ownership
internal static var screenLeaveSpaceTitleLastOwner: String { return L10n.tr("Localizable", "screen_leave_space_title_last_owner") } internal static var screenLeaveSpaceTitleLastOwner: String { return L10n.tr("Localizable", "screen_leave_space_title_last_owner") }
/// Confirm it's you before linking a new device.
internal static var screenLinkNewDeviceAuthenticationReasonIos: String { return L10n.tr("Localizable", "screen_link_new_device_authentication_reason_ios") }
/// Scan the QR code /// Scan the QR code
internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") } internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") }
/// Open %1$@ on a laptop or desktop computer /// Open %1$@ on a laptop or desktop computer
@@ -1001,6 +1001,48 @@ nonisolated class AppLockServiceMock: AppLockServiceProtocol, @unchecked Sendabl
return unlockWithBiometricsReturnValue return unlockWithBiometricsReturnValue
} }
} }
//MARK: - verifyDeviceOwner
private let verifyDeviceOwnerReasonCallsCountLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingCallsCount = 0
var verifyDeviceOwnerReasonCallsCount: Int {
get { verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount } }
set { verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount = newValue } }
}
var verifyDeviceOwnerReasonCalled: Bool {
return verifyDeviceOwnerReasonCallsCount > 0
}
private let verifyDeviceOwnerReasonReceivedReasonLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReceivedReason: String?
var verifyDeviceOwnerReasonReceivedReason: String? {
get { verifyDeviceOwnerReasonReceivedReasonLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedReason } }
set { verifyDeviceOwnerReasonReceivedReasonLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedReason = newValue } }
}
private let verifyDeviceOwnerReasonReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReceivedInvocations: [String] = []
var verifyDeviceOwnerReasonReceivedInvocations: [String] {
get { verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations } }
set { verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations = newValue } }
}
private let verifyDeviceOwnerReasonReturnValueLock = NSLock()
private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReturnValue: AppLockDeviceOwnerResult!
var verifyDeviceOwnerReasonReturnValue: AppLockDeviceOwnerResult! {
get { verifyDeviceOwnerReasonReturnValueLock.withLock { verifyDeviceOwnerReasonUnderlyingReturnValue } }
set { verifyDeviceOwnerReasonReturnValueLock.withLock { verifyDeviceOwnerReasonUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var verifyDeviceOwnerReasonClosure: ((String) async -> AppLockDeviceOwnerResult)?
@concurrent func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult {
verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount += 1 }
verifyDeviceOwnerReasonReceivedReason = reason
verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations.append(reason) }
if let verifyDeviceOwnerReasonClosure = verifyDeviceOwnerReasonClosure {
return await verifyDeviceOwnerReasonClosure(reason)
} else {
return verifyDeviceOwnerReasonReturnValue
}
}
} }
nonisolated class AppMediatorMock: AppMediatorProtocol, @unchecked Sendable { nonisolated class AppMediatorMock: AppMediatorProtocol, @unchecked Sendable {
var windowManager: WindowManagerProtocol { var windowManager: WindowManagerProtocol {
@@ -16,6 +16,7 @@ enum LinkNewDeviceScreenCoordinatorAction {
struct LinkNewDeviceScreenCoordinatorParameters { struct LinkNewDeviceScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol let clientProxy: ClientProxyProtocol
let appLockService: AppLockServiceProtocol
let orientationManager: OrientationManagerProtocol let orientationManager: OrientationManagerProtocol
} }
@@ -31,7 +32,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: LinkNewDeviceScreenCoordinatorParameters) { init(parameters: LinkNewDeviceScreenCoordinatorParameters) {
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy) viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy,
appLockService: parameters.appLockService)
orientationManager = parameters.orientationManager orientationManager = parameters.orientationManager
} }
@@ -15,8 +15,14 @@ enum LinkNewDeviceScreenViewModelAction {
struct LinkNewDeviceScreenViewState: BindableState { struct LinkNewDeviceScreenViewState: BindableState {
nonisolated enum Mode: Equatable { nonisolated enum Mode: Equatable {
enum ReadyState: Equatable {
case idle
case verifyingDeviceOwner
case generatingCode
}
case loading case loading
case readyToLink(isGeneratingCode: Bool) case readyToLink(ReadyState)
case error(QRCodeLoginState.ErrorState) case error(QRCodeLoginState.ErrorState)
} }
@@ -12,15 +12,19 @@ import SwiftUI
typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction> typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction>
class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol { class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol {
private enum Device { case mobileDevice, desktopComputer }
private let clientProxy: ClientProxyProtocol private let clientProxy: ClientProxyProtocol
private let appLockService: AppLockServiceProtocol
private let actionsSubject: PassthroughSubject<LinkNewDeviceScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<LinkNewDeviceScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<LinkNewDeviceScreenViewModelAction, Never> { var actionsPublisher: AnyPublisher<LinkNewDeviceScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(clientProxy: ClientProxyProtocol, initialState: LinkNewDeviceScreenViewState? = nil) { init(clientProxy: ClientProxyProtocol, appLockService: AppLockServiceProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
self.clientProxy = clientProxy self.clientProxy = clientProxy
self.appLockService = appLockService
if let initialState { if let initialState {
super.init(initialViewState: initialState) super.init(initialViewState: initialState)
@@ -41,9 +45,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch viewAction { switch viewAction {
case .linkMobileDevice: case .linkMobileDevice:
Task { await linkMobileDevice() } Task { await verifyAndLink(.mobileDevice) }
case .linkDesktopComputer: case .linkDesktopComputer:
actionsSubject.send(.linkDesktopComputer) Task { await verifyAndLink(.desktopComputer) }
case .errorAction(let action): case .errorAction(let action):
handleErrorAction(action) handleErrorAction(action)
case .dismiss: case .dismiss:
@@ -55,14 +59,41 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
private func checkQRCodeLoginSupport() async { private func checkQRCodeLoginSupport() async {
if await clientProxy.isLoginWithQRCodeSupported { if await clientProxy.isLoginWithQRCodeSupported {
state.mode = .readyToLink(isGeneratingCode: false) state.mode = .readyToLink(.idle)
} else { } else {
state.mode = .error(.notSupported) 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 { private func linkMobileDevice() async {
state.mode = .readyToLink(isGeneratingCode: true) state.mode = .readyToLink(.generatingCode)
let linkNewDeviceService = clientProxy.linkNewDeviceService() let linkNewDeviceService = clientProxy.linkNewDeviceService()
@@ -77,7 +108,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
} }
actionsSubject.send(.linkMobileDevice(progressPublisher)) actionsSubject.send(.linkMobileDevice(progressPublisher))
state.mode = .readyToLink(isGeneratingCode: false) state.mode = .readyToLink(.idle)
} catch { } catch {
// This is hard to share a mapping from the QRCodeLoginError with the // This is hard to share a mapping from the QRCodeLoginError with the
// QRCodeLoginScreen given that some of those are scan errors… // QRCodeLoginScreen given that some of those are scan errors…
@@ -89,7 +120,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
switch action { switch action {
case .startOver: case .startOver:
// Reset to ready state to allow trying again. // Reset to ready state to allow trying again.
state.mode = .readyToLink(isGeneratingCode: false) state.mode = .readyToLink(.idle)
case .openSettings, .signInManually: case .openSettings, .signInManually:
MXLog.error("Unexpected error action: \(action)") MXLog.error("Unexpected error action: \(action)")
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
@@ -59,7 +59,8 @@ struct LinkNewDeviceScreen: View {
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
.disabled(true) .disabled(true)
case .readyToLink(let isGeneratingCode): case .readyToLink(let linkState):
let isGeneratingCode = linkState == .generatingCode
VStack(spacing: 16) { VStack(spacing: 16) {
Button { context.send(viewAction: .linkMobileDevice) } label: { Button { context.send(viewAction: .linkMobileDevice) } label: {
Label { Label {
@@ -84,7 +85,7 @@ struct LinkNewDeviceScreen: View {
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer) .accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
} }
} }
.disabled(isGeneratingCode) .disabled(linkState != .idle)
case .error: case .error:
EmptyView() // Not reachable. EmptyView() // Not reachable.
} }
@@ -103,8 +104,9 @@ struct LinkNewDeviceScreen: View {
// MARK: - Previews // MARK: - Previews
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false)) static let viewModel = makeViewModel(mode: .readyToLink(.idle))
static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true)) static let verifyingViewModel = makeViewModel(mode: .readyToLink(.verifyingDeviceOwner))
static let generatingViewModel = makeViewModel(mode: .readyToLink(.generatingCode))
static let loadingViewModel = makeViewModel(mode: .loading) static let loadingViewModel = makeViewModel(mode: .loading)
static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported)) static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported))
static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown)) static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown))
@@ -114,13 +116,19 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
LinkNewDeviceScreen(context: viewModel.context) LinkNewDeviceScreen(context: viewModel.context)
} }
.previewDisplayName("Ready") .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 { ElementNavigationStack {
LinkNewDeviceScreen(context: generatingViewModel.context) LinkNewDeviceScreen(context: generatingViewModel.context)
} }
.previewDisplayName("Generating") .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 { ElementNavigationStack {
LinkNewDeviceScreen(context: loadingViewModel.context) LinkNewDeviceScreen(context: loadingViewModel.context)
@@ -142,6 +150,7 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel { static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel {
LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()), LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()),
appLockService: AppLockServiceMock.mock(),
initialState: .init(mode: mode, initialState: .init(mode: mode,
showLinkDesktopComputerButton: true)) showLinkDesktopComputerButton: true))
} }
@@ -17,6 +17,7 @@ class AppLockService: AppLockServiceProtocol {
private let timer: AppLockTimer private let timer: AppLockTimer
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication
var isMandatory: Bool { var isMandatory: Bool {
appSettings.appLockIsMandatory appSettings.appLockIsMandatory
@@ -163,6 +164,30 @@ class AppLockService: AppLockServiceProtocol {
} }
} }
func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult {
let context = verificationContext()
var error: NSError?
guard context.canEvaluatePolicy(deviceOwnerPolicy, error: &error) else {
MXLog.warning("Device owner verification unavailable: \(String(describing: error))")
return isEnabled ? .appLockPINRequired : .unavailable
}
do {
guard try await context.evaluatePolicy(deviceOwnerPolicy, localizedReason: reason) else {
MXLog.warning("Device owner verification completed without success.")
return .unverified
}
return .verified
} catch LAError.userCancel, LAError.systemCancel {
MXLog.info("Device owner verification was cancelled.")
return .cancelled
} catch {
MXLog.warning("Device owner verification failed: \(error)")
return .error
}
}
// MARK: - Private // MARK: - Private
/// Queries the context for supported biometrics and enrolment state. /// Queries the context for supported biometrics and enrolment state.
@@ -187,6 +212,12 @@ class AppLockService: AppLockServiceProtocol {
return context return context
} }
/// Creates a fresh context for device owner verification so the user is always prompted.
private func verificationContext() -> LAContext {
// Keep using the injected context for tests etc.
type(of: context) == LAContext.self ? LAContext() : context
}
/// Shared logic for completing an unlock via a PIN or biometrics. /// Shared logic for completing an unlock via a PIN or biometrics.
private func completeUnlock() { private func completeUnlock() {
timer.registerUnlock() timer.registerUnlock()
@@ -34,6 +34,22 @@ enum AppLockServiceBiometricResult {
case interrupted case interrupted
} }
/// The result of an attempt to verify that the user is the device owner.
enum AppLockDeviceOwnerResult {
/// The device owner was successfully verified.
case verified
/// The verification completed without verifying the user.
case unverified
/// Verification isn't available because a device passcode isn't set.
case unavailable
/// Verification isn't available, but an App Lock PIN is set and can be used instead.
case appLockPINRequired
/// The verification attempt was cancelled.
case cancelled
/// The verification couldn't be completed due to an error.
case error
}
protocol AppLockServiceProtocol: AnyObject { protocol AppLockServiceProtocol: AnyObject {
/// The use of a PIN code is mandatory for this device. /// The use of a PIN code is mandatory for this device.
var isMandatory: Bool { get } var isMandatory: Bool { get }
@@ -72,6 +88,13 @@ protocol AppLockServiceProtocol: AnyObject {
/// Attempt to unlock the app using FaceID or TouchID. /// Attempt to unlock the app using FaceID or TouchID.
func unlockWithBiometrics() async -> AppLockServiceBiometricResult func unlockWithBiometrics() async -> AppLockServiceBiometricResult
/// Verifies the device owner via the device passcode/biometrics, e.g. to gate a sensitive action.
///
/// This is a standalone identity check: it does **not** unlock the app or touch any App Lock
/// state (the grace-period timer or PIN attempt count).
/// - Parameter reason: The localised reason shown in the system prompt.
func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult
/// The number of attempts the user had made to unlock with a PIN code. /// The number of attempts the user had made to unlock with a PIN code.
/// ///
/// Note: We don't track the biometric attempts as LAContext does that automatically. /// Note: We don't track the biometric attempts as LAContext does that automatically.
@@ -15,7 +15,7 @@ final nonisolated class AppLockTimer {
/// Whether the timer considers the app to be locked or not. Always starts with a locked app. /// Whether the timer considers the app to be locked or not. Always starts with a locked app.
/// ///
/// Internally this value may be incorrect, always call `needsUnlock` to get the correct value. /// Internally this value may be incorrect, always call ``computeLockState`` to get the correct value.
private var isLocked = true private var isLocked = true
/// The date when the app was last backgrounded whilst in an unlocked state. /// The date when the app was last backgrounded whilst in an unlocked state.
private var lastUnlockedBackground: Date? private var lastUnlockedBackground: Date?
@@ -25,15 +25,26 @@ nonisolated class LAContextMock: LAContext {
internalEvaluatedPolicyDomainStateValue internalEvaluatedPolicyDomainStateValue
} }
var canEvaluatePolicyReturnValue: Bool?
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool { override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
let result = super.canEvaluatePolicy(policy, error: error) let result = canEvaluatePolicyReturnValue ?? super.canEvaluatePolicy(policy, error: error)
updateInternalValues() updateInternalValues()
return result return result
} }
var evaluatePolicyReturnValue: Bool! var evaluatePolicyReturnValue: Bool!
var evaluatePolicyThrowableError: Error?
var evaluatePolicyCallsCount = 0
var evaluatePolicyCalled: Bool {
evaluatePolicyCallsCount > 0
}
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool { override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
evaluatePolicyCallsCount += 1
updateInternalValues() updateInternalValues()
if let evaluatePolicyThrowableError {
throw evaluatePolicyThrowableError
}
return evaluatePolicyReturnValue return evaluatePolicyReturnValue
} }
@@ -784,6 +784,7 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator() let navigationStackCoordinator = NavigationStackCoordinator()
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator, let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
appLockService: AppLockServiceMock.mock(),
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)), flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
bugReportService: BugReportServiceMock(.init()), bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()),
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c550f1ef9a8e960e08afd8b340442ad99b9c73ff62f45cbe998da9ae05405ea5
size 111107
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79f1a9f769ba0b72a31f825e3c60828be64a4ca1084a939b57b8b79ca2ed33c5
size 122727
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:217d5d9041037d68c23fbd9c5f5f07aa1c481ee43e3814794c709fd19a5bc505
size 62602
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0675d204a7f5e934cb52f66ecd1840471480575a17f81e64866cf11df98cd8da
size 80524
@@ -8,6 +8,7 @@
@testable import ElementX @testable import ElementX
import Foundation import Foundation
import LocalAuthentication
import Testing import Testing
@MainActor @MainActor
@@ -346,4 +347,100 @@ final class AppLockServiceTests {
// Then the attempts counts should both be reset. // Then the attempts counts should both be reset.
#expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.") #expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.")
} }
// MARK: - Device Owner Verification
@Test
func deviceOwnerVerified() async {
// Given a service that can evaluate the device owner policy successfully.
let context = LAContextMock()
context.canEvaluatePolicyReturnValue = true
context.evaluatePolicyReturnValue = true
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
// When verifying the device owner.
let result = await service.verifyDeviceOwner(reason: "Test")
// Then the owner should be verified.
#expect(result == .verified)
}
@Test
func deviceOwnerUnverified() async {
// Given a service where the policy evaluation completes without success.
let context = LAContextMock()
context.canEvaluatePolicyReturnValue = true
context.evaluatePolicyReturnValue = false
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
// When verifying the device owner.
let result = await service.verifyDeviceOwner(reason: "Test")
// Then the result should be unverified.
#expect(result == .unverified)
}
@Test
func deviceOwnerVerificationCancelled() async {
// Given a service where the user cancels the prompt.
let context = LAContextMock()
context.canEvaluatePolicyReturnValue = true
context.evaluatePolicyThrowableError = LAError(.userCancel)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
// When verifying the device owner.
let result = await service.verifyDeviceOwner(reason: "Test")
// Then the result should be cancelled.
#expect(result == .cancelled)
}
@Test
func deviceOwnerVerificationErrored() async {
// Given a service where the policy evaluation throws an error.
let context = LAContextMock()
context.canEvaluatePolicyReturnValue = true
context.evaluatePolicyThrowableError = LAError(.authenticationFailed)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
// When verifying the device owner.
let result = await service.verifyDeviceOwner(reason: "Test")
// Then the result should be an error.
#expect(result == .error)
}
@Test
func deviceOwnerVerificationUnavailableWithoutPIN() async {
// Given a service that can't evaluate the policy and has no App Lock PIN set.
let context = LAContextMock()
context.canEvaluatePolicyReturnValue = false
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
#expect(!service.isEnabled, "The service shouldn't be enabled.")
// When verifying the device owner.
let result = await service.verifyDeviceOwner(reason: "Test")
// Then verification should be unavailable.
#expect(result == .unavailable)
}
@Test
func deviceOwnerVerificationRequiresAppLockPIN() async {
// Given a service that can't evaluate the policy but has an App Lock PIN set.
let context = LAContextMock()
context.canEvaluatePolicyReturnValue = false
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
Issue.record("The PIN should be valid.")
return
}
#expect(service.isEnabled, "The service should be enabled.")
// When verifying the device owner.
let result = await service.verifyDeviceOwner(reason: "Test")
// Then it should request the App Lock PIN instead.
#expect(result == .appLockPINRequired)
}
} }
@@ -0,0 +1,262 @@
//
// 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
@testable import ElementX
import SwiftUI
import Testing
@MainActor
class LinkNewDeviceScreenViewModelTests {
var linkNewDeviceService: LinkNewDeviceServiceMock!
var appLockService: AppLockServiceMock!
var viewModel: LinkNewDeviceScreenViewModel!
var context: LinkNewDeviceScreenViewModel.Context {
viewModel.context
}
// MARK: - QR code support
@Test
func readyWhenQRCodeLoginSupported() async throws {
// Given a client that supports logging in with a QR code.
setupViewModel(isLoginWithQRCodeSupported: true)
// Then the screen should become ready to link.
let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) }
try await deferred.fulfill()
}
@Test
func errorWhenQRCodeLoginNotSupported() async throws {
// Given a client that doesn't support logging in with a QR code.
setupViewModel(isLoginWithQRCodeSupported: false)
// Then the screen should show the unsupported error.
let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .error(.notSupported) }
try await deferred.fulfill()
}
// MARK: - Linking
@Test
func linkingMobileDeviceVerifiesThenGeneratesQRCode() async throws {
// Given a ready screen whose QR code isn't immediately available.
let progressSubject = CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.starting)
setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(),
mode: .loading)
// When linking a mobile device.
let deferredGenerating = deferFulfillment(context.observe(\.viewState.mode),
transitionValues: [.readyToLink(.verifyingDeviceOwner),
.readyToLink(.generatingCode)])
context.send(viewAction: .linkMobileDevice)
// Then it should verify the device owner and start generating the QR code.
try await deferredGenerating.fulfill()
#expect(appLockService.verifyDeviceOwnerReasonCalled)
#expect(linkNewDeviceService.linkMobileDeviceCalled)
// When the QR code has been generated.
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLinkMobileDevice }
let deferredIdle = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) }
progressSubject.send(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
// Then the flow should continue with linking the mobile device.
try await deferredAction.fulfill()
try await deferredIdle.fulfill()
}
@Test
func linkingDesktopComputerVerifiesThenEmitsAction() async throws {
// Given a ready screen.
setupViewModel(mode: .loading)
// When linking a desktop computer.
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLinkDesktopComputer }
let deferredMode = deferFulfillment(context.observe(\.viewState.mode),
transitionValues: [.readyToLink(.verifyingDeviceOwner), .readyToLink(.idle)])
context.send(viewAction: .linkDesktopComputer)
// Then it should verify the device owner, and continue the flow to link a desktop.
try await deferredAction.fulfill()
try await deferredMode.fulfill()
#expect(appLockService.verifyDeviceOwnerReasonCalled)
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
}
@Test
func linkingProceedsWhenVerificationUnavailable() async throws {
// Given a device with no passcode set, so device owner verification is unavailable.
let progressSubject = CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.starting)
setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(),
deviceOwnerResult: .unavailable,
mode: .loading)
// When linking a mobile device.
let deferredGenerating = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.generatingCode) }
context.send(viewAction: .linkMobileDevice)
// Then it should proceed by generating a QR code.
try await deferredGenerating.fulfill()
#expect(appLockService.verifyDeviceOwnerReasonCalled)
#expect(linkNewDeviceService.linkMobileDeviceCalled)
}
@Test
func appLockPINRequiredDoesNotLink() async throws {
// Given a device with no passcode but an App Lock PIN set, so PIN verification is required.
setupViewModel(deviceOwnerResult: .appLockPINRequired,
mode: .loading)
// When linking a mobile device, it should not proceed to generate a QR code.
// TODO: assert the App Lock PIN screen is presented once it's wired up.
let deferred = deferFailure(context.observe(\.viewState.mode), timeout: .seconds(1)) { $0 == .readyToLink(.generatingCode) }
context.send(viewAction: .linkMobileDevice)
try await deferred.fulfill()
// Then verification should have been attempted but linking should not have started.
#expect(appLockService.verifyDeviceOwnerReasonCalled)
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
}
// MARK: - Verification failures
@Test
func cancellingVerificationReturnsToIdle() async throws {
// Given a screen where the user will cancel the device owner verification.
setupViewModel(deviceOwnerResult: .cancelled,
mode: .loading)
// When linking a mobile device.
let deferred = deferFulfillment(context.observe(\.viewState.mode),
transitionValues: [.readyToLink(.verifyingDeviceOwner), .readyToLink(.idle)])
context.send(viewAction: .linkMobileDevice)
// Then the cancellation should silently return to idle without generating a QR code.
try await deferred.fulfill()
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
}
@Test
func unverifiedReturnsToIdle() async throws {
// Given a screen where the device owner verification completes without success.
setupViewModel(deviceOwnerResult: .unverified,
mode: .loading)
// When linking a mobile device.
let deferred = deferFulfillment(context.observe(\.viewState.mode),
transitionValues: [.readyToLink(.verifyingDeviceOwner), .readyToLink(.idle)])
context.send(viewAction: .linkMobileDevice)
// Then the unverified result should silently return to idle without generating a QR code.
try await deferred.fulfill()
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
}
@Test
func verificationErrorShowsError() async throws {
// Given a screen where the device owner verification errors.
setupViewModel(deviceOwnerResult: .error,
mode: .loading)
// When linking a mobile device.
let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .error(.unknown) }
context.send(viewAction: .linkMobileDevice)
// Then the error should surface without generating a QR code.
try await deferred.fulfill()
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
}
// MARK: - Error actions
@Test
func startingOverReturnsToIdle() async throws {
// Given a screen in an error state.
setupViewModel(mode: .error(.unknown))
// When starting over.
let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) }
context.send(viewAction: .errorAction(.startOver))
// Then it should return to idle.
try await deferred.fulfill()
}
@Test
func cancellingErrorEmitsDismiss() async throws {
// Given a screen in an error state.
setupViewModel(mode: .error(.unknown))
// When cancelling.
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0.isDismiss }
context.send(viewAction: .errorAction(.cancel))
// Then it should dismiss the flow.
try await deferred.fulfill()
}
@Test
func dismissEmitsAction() async throws {
// Given a ready screen.
setupViewModel(mode: .readyToLink(.idle))
// When dismissing.
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0.isDismiss }
context.send(viewAction: .dismiss)
// Then it should dismiss the flow.
try await deferred.fulfill()
}
// MARK: - Helpers
private func setupViewModel(isLoginWithQRCodeSupported: Bool = true,
linkMobileProgressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage)),
deviceOwnerResult: AppLockDeviceOwnerResult = .verified,
mode: LinkNewDeviceScreenViewState.Mode? = nil) {
let clientProxy = ClientProxyMock(.init())
clientProxy.underlyingIsLoginWithQRCodeSupported = isLoginWithQRCodeSupported
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressPublisher))
clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService
appLockService = AppLockServiceMock.mock()
appLockService.verifyDeviceOwnerReasonReturnValue = deviceOwnerResult
let initialState = mode.map { LinkNewDeviceScreenViewState(mode: $0, showLinkDesktopComputerButton: true) }
viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy,
appLockService: appLockService,
initialState: initialState)
}
}
private extension LinkNewDeviceScreenViewModelAction {
var isLinkMobileDevice: Bool {
switch self {
case .linkMobileDevice: true
default: false
}
}
var isLinkDesktopComputer: Bool {
switch self {
case .linkDesktopComputer: true
default: false
}
}
var isDismiss: Bool {
switch self {
case .dismiss: true
default: false
}
}
}