mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Ensure that the current user is the device owner before linking a new device. (#5773)
This commit is contained in:
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user