mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Ensure that the current user is the device owner before linking a new device. (#5773)
This commit is contained in:
@@ -313,6 +313,7 @@
|
||||
31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; };
|
||||
32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.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 */; };
|
||||
33BA0964A308D2286B39976D /* LabsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -4982,6 +4984,7 @@
|
||||
DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */,
|
||||
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */,
|
||||
C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */,
|
||||
E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */,
|
||||
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */,
|
||||
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */,
|
||||
EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */,
|
||||
@@ -7955,6 +7958,7 @@
|
||||
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */,
|
||||
BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */,
|
||||
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */,
|
||||
33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */,
|
||||
CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */,
|
||||
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */,
|
||||
F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */,
|
||||
|
||||
@@ -932,6 +932,7 @@
|
||||
"screen_leave_space_title" = "Leave %1$@?";
|
||||
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
|
||||
"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_step1" = "Open %1$@ on a laptop or desktop computer";
|
||||
"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.";
|
||||
"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.";
|
||||
"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.";
|
||||
|
||||
@@ -932,6 +932,7 @@
|
||||
"screen_leave_space_title" = "Leave %1$@?";
|
||||
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
|
||||
"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_step1" = "Open %1$@ on a laptop or desktop computer";
|
||||
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
|
||||
|
||||
@@ -15,6 +15,7 @@ enum LinkNewDeviceFlowCoordinatorAction {
|
||||
|
||||
class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||
private let appLockService: AppLockServiceProtocol
|
||||
private let flowParameters: CommonFlowParameters
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -25,8 +26,10 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
|
||||
init(navigationStackCoordinator: NavigationStackCoordinator,
|
||||
appLockService: AppLockServiceProtocol,
|
||||
flowParameters: CommonFlowParameters) {
|
||||
self.navigationStackCoordinator = navigationStackCoordinator
|
||||
self.appLockService = appLockService
|
||||
self.flowParameters = flowParameters
|
||||
}
|
||||
|
||||
@@ -44,6 +47,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
private func presentLinkNewDeviceScreen() {
|
||||
let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy,
|
||||
appLockService: appLockService,
|
||||
orientationManager: flowParameters.appMediator.windowManager))
|
||||
coordinator.actionsPublisher
|
||||
.sink { [weak self] action in
|
||||
|
||||
@@ -181,6 +181,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private func startLinkNewDeviceFlow() {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator,
|
||||
appLockService: appLockService,
|
||||
flowParameters: flowParameters)
|
||||
flowCoordinator.actionsPublisher
|
||||
.sink { [weak self] action in
|
||||
|
||||
@@ -2170,6 +2170,8 @@ internal nonisolated enum L10n {
|
||||
}
|
||||
/// Transfer ownership
|
||||
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
|
||||
internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") }
|
||||
/// Open %1$@ on a laptop or desktop computer
|
||||
|
||||
@@ -1001,6 +1001,48 @@ nonisolated class AppLockServiceMock: AppLockServiceProtocol, @unchecked Sendabl
|
||||
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 {
|
||||
var windowManager: WindowManagerProtocol {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class AppLockService: AppLockServiceProtocol {
|
||||
|
||||
private let timer: AppLockTimer
|
||||
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
||||
private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication
|
||||
|
||||
var isMandatory: Bool {
|
||||
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
|
||||
|
||||
/// Queries the context for supported biometrics and enrolment state.
|
||||
@@ -187,6 +212,12 @@ class AppLockService: AppLockServiceProtocol {
|
||||
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.
|
||||
private func completeUnlock() {
|
||||
timer.registerUnlock()
|
||||
|
||||
@@ -34,6 +34,22 @@ enum AppLockServiceBiometricResult {
|
||||
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 {
|
||||
/// The use of a PIN code is mandatory for this device.
|
||||
var isMandatory: Bool { get }
|
||||
@@ -72,6 +88,13 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
/// Attempt to unlock the app using FaceID or TouchID.
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// The date when the app was last backgrounded whilst in an unlocked state.
|
||||
private var lastUnlockedBackground: Date?
|
||||
|
||||
@@ -25,15 +25,26 @@ nonisolated class LAContextMock: LAContext {
|
||||
internalEvaluatedPolicyDomainStateValue
|
||||
}
|
||||
|
||||
var canEvaluatePolicyReturnValue: 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()
|
||||
return result
|
||||
}
|
||||
|
||||
var evaluatePolicyReturnValue: Bool!
|
||||
var evaluatePolicyThrowableError: Error?
|
||||
var evaluatePolicyCallsCount = 0
|
||||
var evaluatePolicyCalled: Bool {
|
||||
evaluatePolicyCallsCount > 0
|
||||
}
|
||||
|
||||
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
|
||||
evaluatePolicyCallsCount += 1
|
||||
updateInternalValues()
|
||||
if let evaluatePolicyThrowableError {
|
||||
throw evaluatePolicyThrowableError
|
||||
}
|
||||
return evaluatePolicyReturnValue
|
||||
}
|
||||
|
||||
|
||||
@@ -784,6 +784,7 @@ class MockScreen: Identifiable {
|
||||
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
|
||||
appLockService: AppLockServiceMock.mock(),
|
||||
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
bugReportService: BugReportServiceMock(.init()),
|
||||
elementCallService: ElementCallServiceMock(.init()),
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c550f1ef9a8e960e08afd8b340442ad99b9c73ff62f45cbe998da9ae05405ea5
|
||||
size 111107
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79f1a9f769ba0b72a31f825e3c60828be64a4ca1084a939b57b8b79ca2ed33c5
|
||||
size 122727
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:217d5d9041037d68c23fbd9c5f5f07aa1c481ee43e3814794c709fd19a5bc505
|
||||
size 62602
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0675d204a7f5e934cb52f66ecd1840471480575a17f81e64866cf11df98cd8da
|
||||
size 80524
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
@testable import ElementX
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
@@ -346,4 +347,100 @@ final class AppLockServiceTests {
|
||||
// Then the attempts counts should both 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user