mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Make LinkNewDeviceScreen fall back to the App Lock PIN code when there's no passcode. (#5794)
* Fall back to the AppLock PIN code when there's no passcode. * Add extra tests on the LinkNewDeviceFlowCoordinator. * Misc tweaks for clarification. * Regenerate snapshots.
This commit is contained in:
@@ -242,6 +242,8 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
|||||||
stateMachine.tryEvent(.didUnlockWithPIN)
|
stateMachine.tryEvent(.didUnlockWithPIN)
|
||||||
case .forceLogout:
|
case .forceLogout:
|
||||||
stateMachine.tryEvent(.forceLogout)
|
stateMachine.tryEvent(.forceLogout)
|
||||||
|
case .cancelVerifyDeviceOwner:
|
||||||
|
fatalError("Cancelling is not allowed when unlocking the app.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Foundation
|
|||||||
|
|
||||||
enum LinkNewDeviceFlowCoordinatorAction {
|
enum LinkNewDeviceFlowCoordinatorAction {
|
||||||
case requestOAuthAuthorisation(URL, OAuthAccountSettingsPresenter.Continuation)
|
case requestOAuthAuthorisation(URL, OAuthAccountSettingsPresenter.Continuation)
|
||||||
|
/// The user failed to remember their App Lock PIN.
|
||||||
|
case forceLogout
|
||||||
case dismiss
|
case dismiss
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,8 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
presentQRCodeScreen(mode: .linkMobile(progressPublisher, flowParameters.userSession.clientProxy))
|
presentQRCodeScreen(mode: .linkMobile(progressPublisher, flowParameters.userSession.clientProxy))
|
||||||
case .linkDesktopComputer:
|
case .linkDesktopComputer:
|
||||||
presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService()))
|
presentQRCodeScreen(mode: .linkDesktop(flowParameters.userSession.clientProxy.linkNewDeviceService()))
|
||||||
|
case .verifyWithAppLockPIN(let continuation):
|
||||||
|
presentAppLockScreen(continuation: continuation)
|
||||||
case .dismiss:
|
case .dismiss:
|
||||||
actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,33 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentAppLockScreen(continuation: CheckedContinuation<Bool, Never>) {
|
||||||
|
// We can use the AppLockScreen as a fallback here. It might seem weird but in the end it only calls
|
||||||
|
// AppLockService.unlock which is a no-op when the app is already unlocked.
|
||||||
|
let stackCoordinator = NavigationStackCoordinator()
|
||||||
|
let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService, mode: .verifyDeviceOwner))
|
||||||
|
|
||||||
|
coordinator.actions
|
||||||
|
.sink { [weak self] action in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case .appUnlocked:
|
||||||
|
continuation.resume(returning: true)
|
||||||
|
navigationStackCoordinator.setFullScreenCoverCoordinator(nil)
|
||||||
|
case .cancelVerifyDeviceOwner:
|
||||||
|
continuation.resume(returning: false)
|
||||||
|
navigationStackCoordinator.setFullScreenCoverCoordinator(nil)
|
||||||
|
case .forceLogout:
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
stackCoordinator.setRootCoordinator(coordinator)
|
||||||
|
navigationStackCoordinator.setFullScreenCoverCoordinator(stackCoordinator)
|
||||||
|
}
|
||||||
|
|
||||||
private func presentQRCodeScreen(mode: QRCodeLoginScreenMode) {
|
private func presentQRCodeScreen(mode: QRCodeLoginScreenMode) {
|
||||||
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(mode: mode,
|
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(mode: mode,
|
||||||
canSignInManually: false, // No need to worry about this when linking a device.
|
canSignInManually: false, // No need to worry about this when linking a device.
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||||
case .requestOAuthAuthorisation(let url, let continuation):
|
case .requestOAuthAuthorisation(let url, let continuation):
|
||||||
presentAccountManagementURL(url, continuation: continuation)
|
presentAccountManagementURL(url, continuation: continuation)
|
||||||
|
case .forceLogout:
|
||||||
|
actionsSubject.send(.forceLogout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ enum A11yIdentifiers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppLockScreen {
|
struct AppLockScreen {
|
||||||
|
let cancel = "app_lock-cancel"
|
||||||
func numpad(_ digit: Int) -> String {
|
func numpad(_ digit: Int) -> String {
|
||||||
"app_lock-numpad_\(digit)"
|
"app_lock-numpad_\(digit)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import SwiftUI
|
|||||||
struct AppLockScreenCoordinatorParameters {
|
struct AppLockScreenCoordinatorParameters {
|
||||||
/// The service used to unlock the app.
|
/// The service used to unlock the app.
|
||||||
let appLockService: AppLockServiceProtocol
|
let appLockService: AppLockServiceProtocol
|
||||||
|
/// The context in which the screen is being shown.
|
||||||
|
var mode: AppLockScreenMode = .appUnlock
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppLockScreenCoordinatorAction {
|
enum AppLockScreenCoordinatorAction {
|
||||||
@@ -19,6 +21,8 @@ enum AppLockScreenCoordinatorAction {
|
|||||||
case appUnlocked
|
case appUnlocked
|
||||||
/// The user failed to unlock the app (or forgot their PIN).
|
/// The user failed to unlock the app (or forgot their PIN).
|
||||||
case forceLogout
|
case forceLogout
|
||||||
|
/// The user cancelled device owner verification. Only sent in the `.verifyDeviceOwner` mode.
|
||||||
|
case cancelVerifyDeviceOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppLockScreenCoordinator: CoordinatorProtocol {
|
final class AppLockScreenCoordinator: CoordinatorProtocol {
|
||||||
@@ -31,7 +35,7 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(parameters: AppLockScreenCoordinatorParameters) {
|
init(parameters: AppLockScreenCoordinatorParameters) {
|
||||||
viewModel = AppLockScreenViewModel(appLockService: parameters.appLockService)
|
viewModel = AppLockScreenViewModel(appLockService: parameters.appLockService, mode: parameters.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -44,6 +48,8 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
|
|||||||
self.actionsSubject.send(.appUnlocked)
|
self.actionsSubject.send(.appUnlocked)
|
||||||
case .forceLogout:
|
case .forceLogout:
|
||||||
self.actionsSubject.send(.forceLogout)
|
self.actionsSubject.send(.forceLogout)
|
||||||
|
case .cancelVerifyDeviceOwner:
|
||||||
|
self.actionsSubject.send(.cancelVerifyDeviceOwner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -13,9 +13,21 @@ enum AppLockScreenViewModelAction {
|
|||||||
case appUnlocked
|
case appUnlocked
|
||||||
/// The user failed to unlock the app (or forgot their PIN).
|
/// The user failed to unlock the app (or forgot their PIN).
|
||||||
case forceLogout
|
case forceLogout
|
||||||
|
/// The user cancelled device owner verification. Only sent in the `.verifyDeviceOwner` mode.
|
||||||
|
case cancelVerifyDeviceOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppLockScreenMode {
|
||||||
|
/// Unlocking the app on launch/foregrounding.
|
||||||
|
case appUnlock
|
||||||
|
/// Verifying that the user is the device owner to gate a sensitive action.
|
||||||
|
case verifyDeviceOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AppLockScreenViewState: BindableState {
|
struct AppLockScreenViewState: BindableState {
|
||||||
|
/// The context in which the screen is being shown.
|
||||||
|
var mode: AppLockScreenMode = .appUnlock
|
||||||
|
|
||||||
/// The number of attempts allowed to unlock the app.
|
/// The number of attempts allowed to unlock the app.
|
||||||
let maximumAttempts = 3
|
let maximumAttempts = 3
|
||||||
|
|
||||||
@@ -64,4 +76,6 @@ enum AppLockScreenViewAction {
|
|||||||
case clearPINCode
|
case clearPINCode
|
||||||
/// The user didn't heed the warnings and can't remember their PIN.
|
/// The user didn't heed the warnings and can't remember their PIN.
|
||||||
case forgotPIN
|
case forgotPIN
|
||||||
|
/// The user cancelled device owner verification.
|
||||||
|
case cancelVerifyDeviceOwner
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
|||||||
actionsSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(appLockService: AppLockServiceProtocol) {
|
init(appLockService: AppLockServiceProtocol, mode: AppLockScreenMode = .appUnlock) {
|
||||||
self.appLockService = appLockService
|
self.appLockService = appLockService
|
||||||
|
|
||||||
super.init(initialViewState: AppLockScreenViewState(bindings: .init()))
|
super.init(initialViewState: AppLockScreenViewState(mode: mode, bindings: .init()))
|
||||||
|
|
||||||
appLockService.numberOfPINAttempts
|
appLockService.numberOfPINAttempts
|
||||||
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
|
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
|
||||||
@@ -51,6 +51,8 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
|||||||
state.bindings.pinCode = ""
|
state.bindings.pinCode = ""
|
||||||
case .forgotPIN:
|
case .forgotPIN:
|
||||||
handleForgotPIN()
|
handleForgotPIN()
|
||||||
|
case .cancelVerifyDeviceOwner:
|
||||||
|
actionsSubject.send(.cancelVerifyDeviceOwner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ struct AppLockScreen: View {
|
|||||||
.animation(.elementDefault, value: context.viewState.forcedLogoutIndicator)
|
.animation(.elementDefault, value: context.viewState.forcedLogoutIndicator)
|
||||||
}
|
}
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
|
.toolbar { toolbar }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
var toolbar: some ToolbarContent {
|
||||||
|
if context.viewState.mode == .verifyDeviceOwner {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
ToolbarButton(role: .cancel) {
|
||||||
|
context.send(viewAction: .cancelVerifyDeviceOwner)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(A11yIdentifiers.appLockScreen.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var header: some View {
|
var header: some View {
|
||||||
@@ -114,10 +127,17 @@ struct AppLockScreen: View {
|
|||||||
|
|
||||||
struct AppLockScreen_Previews: PreviewProvider, TestablePreview {
|
struct AppLockScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = AppLockScreenViewModel(appLockService: AppLockServiceMock.mock())
|
static let viewModel = AppLockScreenViewModel(appLockService: AppLockServiceMock.mock())
|
||||||
|
static let verifyViewModel = AppLockScreenViewModel(appLockService: AppLockServiceMock.mock(), mode: .verifyDeviceOwner)
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ElementNavigationStack {
|
ElementNavigationStack {
|
||||||
AppLockScreen(context: viewModel.context)
|
AppLockScreen(context: viewModel.context)
|
||||||
}
|
}
|
||||||
|
.previewDisplayName("Unlock")
|
||||||
|
|
||||||
|
ElementNavigationStack {
|
||||||
|
AppLockScreen(context: verifyViewModel.context)
|
||||||
|
}
|
||||||
|
.previewDisplayName("Verify")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
enum LinkNewDeviceScreenCoordinatorAction {
|
enum LinkNewDeviceScreenCoordinatorAction {
|
||||||
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||||
case linkDesktopComputer
|
case linkDesktopComputer
|
||||||
|
case verifyWithAppLockPIN(CheckedContinuation<Bool, Never>)
|
||||||
case dismiss
|
case dismiss
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
|
|||||||
actionsSubject.send(.linkMobileDevice(progressPublisher))
|
actionsSubject.send(.linkMobileDevice(progressPublisher))
|
||||||
case .linkDesktopComputer:
|
case .linkDesktopComputer:
|
||||||
actionsSubject.send(.linkDesktopComputer)
|
actionsSubject.send(.linkDesktopComputer)
|
||||||
|
case .verifyWithAppLockPIN(let continuation):
|
||||||
|
actionsSubject.send(.verifyWithAppLockPIN(continuation))
|
||||||
case .dismiss:
|
case .dismiss:
|
||||||
actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
enum LinkNewDeviceScreenViewModelAction {
|
enum LinkNewDeviceScreenViewModelAction {
|
||||||
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
|
||||||
case linkDesktopComputer
|
case linkDesktopComputer
|
||||||
|
case verifyWithAppLockPIN(CheckedContinuation<Bool, Never>)
|
||||||
case dismiss
|
case dismiss
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
case .verified, .unavailable:
|
case .verified, .unavailable:
|
||||||
break
|
break
|
||||||
case .appLockPINRequired:
|
case .appLockPINRequired:
|
||||||
// Follow-up PR: present the App Lock PIN screen to verify the device owner.
|
guard await verifyWithAppLockPIN() else {
|
||||||
return
|
state.mode = .readyToLink(.idle)
|
||||||
|
return
|
||||||
|
}
|
||||||
case .cancelled, .unverified:
|
case .cancelled, .unverified:
|
||||||
state.mode = .readyToLink(.idle)
|
state.mode = .readyToLink(.idle)
|
||||||
return
|
return
|
||||||
@@ -92,6 +94,13 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks the coordinator to verify the device owner with the App Lock PIN, suspending until it has a result.
|
||||||
|
private func verifyWithAppLockPIN() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
actionsSubject.send(.verifyWithAppLockPIN(continuation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func linkMobileDevice() async {
|
private func linkMobileDevice() async {
|
||||||
state.mode = .readyToLink(.generatingCode)
|
state.mode = .readyToLink(.generatingCode)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ class AppLockService: AppLockServiceProtocol {
|
|||||||
private let context: LAContext
|
private let context: LAContext
|
||||||
|
|
||||||
private let timer: AppLockTimer
|
private let timer: AppLockTimer
|
||||||
|
/// Only use Biometrics to unlock the app, we want to fall back to the App Lock PIN, not the device's passcode.
|
||||||
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
||||||
|
/// Use biometrics *or* the device's passcode to verify the device owner. Available regardless of whether biometrics are enrolled.
|
||||||
|
///
|
||||||
|
/// **Note:** Verifying the device owner is used to gate sensitive actions, it isn't related to unlocking the app.
|
||||||
|
/// Maybe we should rename this service in the future.
|
||||||
private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication
|
private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication
|
||||||
|
|
||||||
var isMandatory: Bool {
|
var isMandatory: Bool {
|
||||||
|
|||||||
@@ -88,19 +88,23 @@ 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
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
var numberOfPINAttempts: AnyPublisher<Int, Never> { get }
|
||||||
|
|
||||||
|
// MARK: Device Owner Verification
|
||||||
|
|
||||||
/// Verifies the device owner via the device passcode/biometrics, e.g. to gate a sensitive action.
|
/// 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
|
/// 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).
|
/// state (the grace-period timer or PIN attempt count).
|
||||||
/// - Parameter reason: The localised reason shown in the system prompt.
|
/// - Parameter reason: The localised reason shown in the system prompt.
|
||||||
func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult
|
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.
|
|
||||||
var numberOfPINAttempts: AnyPublisher<Int, Never> { get }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mocks
|
||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
extension AppLockServiceProtocol { }
|
extension AppLockServiceProtocol { }
|
||||||
|
|
||||||
|
|||||||
@@ -775,16 +775,30 @@ class MockScreen: Identifiable {
|
|||||||
coordinator.start()
|
coordinator.start()
|
||||||
|
|
||||||
return navigationStackCoordinator
|
return navigationStackCoordinator
|
||||||
case .linkNewDevice:
|
case .linkNewDevice, .linkNewDeviceWithAppLockPIN:
|
||||||
let linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError> = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
|
let linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError> = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
|
||||||
let linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher()))
|
let linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher()))
|
||||||
|
|
||||||
let clientProxy = ClientProxyMock(.init())
|
let clientProxy = ClientProxyMock(.init())
|
||||||
clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService
|
clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService
|
||||||
|
|
||||||
|
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
||||||
|
keychainController.resetSecrets()
|
||||||
|
|
||||||
|
let context = LAContextMock()
|
||||||
|
context.canEvaluatePolicyReturnValue = false // No device passcode in the simulator.
|
||||||
|
|
||||||
|
let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||||
|
|
||||||
|
// Set an App Lock PIN for the dedicated variant so linking requires it (and forced logout can be tested).
|
||||||
|
// Without a PIN the service is disabled, so verification is unavailable and linking proceeds.
|
||||||
|
if id == .linkNewDeviceWithAppLockPIN, case .failure = appLockService.setupPINCode("2023") {
|
||||||
|
fatalError("Failed to preset the PIN code.")
|
||||||
|
}
|
||||||
|
|
||||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||||
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
|
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
|
||||||
appLockService: AppLockServiceMock.mock(),
|
appLockService: appLockService,
|
||||||
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()),
|
||||||
@@ -804,7 +818,7 @@ class MockScreen: Identifiable {
|
|||||||
switch action {
|
switch action {
|
||||||
case .dismiss:
|
case .dismiss:
|
||||||
navigationRootCoordinator.setSheetCoordinator(nil)
|
navigationRootCoordinator.setSheetCoordinator(nil)
|
||||||
case .requestOAuthAuthorisation:
|
case .requestOAuthAuthorisation, .forceLogout:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ enum UITestsScreenIdentifier: String {
|
|||||||
case encryptionSettingsOutOfSync
|
case encryptionSettingsOutOfSync
|
||||||
case encryptionReset
|
case encryptionReset
|
||||||
case linkNewDevice
|
case linkNewDevice
|
||||||
|
case linkNewDeviceWithAppLockPIN
|
||||||
case roomLayoutBottom
|
case roomLayoutBottom
|
||||||
case roomLayoutMiddle
|
case roomLayoutMiddle
|
||||||
case roomLayoutTop
|
case roomLayoutTop
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2dca9ed5f2e5fe872b14ca4b80a75ca3639691424cec998a984452f5edc58beb
|
||||||
|
size 115003
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:19a50e49aba3847ab76e11d82fa48856b4366484a158d50d0e9eb062700ce6a2
|
||||||
|
size 123576
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9d1f81da13faa600035bccb951adf1d81ed9e2dc5e21cdc782a4fc623243e0dc
|
||||||
|
size 70283
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9333374fd5b3affc6bdff106669b66b468b22b6705c157fe0569c308bb850c04
|
||||||
|
size 77149
|
||||||
@@ -13,6 +13,9 @@ class LinkNewDeviceTests: XCTestCase {
|
|||||||
static let selectDevice = 1
|
static let selectDevice = 1
|
||||||
static let linkMobileDevice = 2
|
static let linkMobileDevice = 2
|
||||||
static let linkDesktopComputer = 3
|
static let linkDesktopComputer = 3
|
||||||
|
static let appLockPIN = 4
|
||||||
|
static let logoutAlert = 5
|
||||||
|
static let forcedLogout = 6
|
||||||
static let dismissed = 99
|
static let dismissed = 99
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,4 +48,57 @@ class LinkNewDeviceTests: XCTestCase {
|
|||||||
cancelButton.tap()
|
cancelButton.tap()
|
||||||
try await app.assertScreenshot(step: Step.dismissed)
|
try await app.assertScreenshot(step: Step.dismissed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAppLockPINVerification() async throws {
|
||||||
|
// Root screen
|
||||||
|
let app = Application.launch(.linkNewDeviceWithAppLockPIN)
|
||||||
|
try await app.assertScreenshot(step: Step.selectDevice)
|
||||||
|
|
||||||
|
// Linking requires verifying the device owner with the App Lock PIN.
|
||||||
|
let mobileDeviceButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.mobileDevice]
|
||||||
|
mobileDeviceButton.tap()
|
||||||
|
try await app.assertScreenshot(step: Step.appLockPIN)
|
||||||
|
|
||||||
|
// Cancelling verification returns to the root screen.
|
||||||
|
app.buttons[A11yIdentifiers.appLockScreen.cancel].tap()
|
||||||
|
try await app.assertScreenshot(step: Step.selectDevice)
|
||||||
|
|
||||||
|
// Entering the correct PIN continues to generate the QR code.
|
||||||
|
mobileDeviceButton.tap()
|
||||||
|
try await app.assertScreenshot(step: Step.appLockPIN)
|
||||||
|
enterPIN(app)
|
||||||
|
try await app.assertScreenshot(step: Step.linkMobileDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForceLogout() async throws {
|
||||||
|
// Root screen
|
||||||
|
let app = Application.launch(.linkNewDeviceWithAppLockPIN)
|
||||||
|
|
||||||
|
// Linking requires verifying the device owner with the App Lock PIN.
|
||||||
|
app.buttons[A11yIdentifiers.linkNewDeviceScreen.mobileDevice].tap()
|
||||||
|
try await app.assertScreenshot(step: Step.appLockPIN)
|
||||||
|
|
||||||
|
// Entering the wrong PIN three times signs the user out.
|
||||||
|
enterWrongPIN(app)
|
||||||
|
enterWrongPIN(app)
|
||||||
|
enterWrongPIN(app)
|
||||||
|
try await app.assertScreenshot(step: Step.logoutAlert)
|
||||||
|
app.alerts.element.buttons[A11yIdentifiers.alertInfo.primaryButton].firstMatch.tap()
|
||||||
|
try await app.assertScreenshot(step: Step.forcedLogout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func enterPIN(_ app: XCUIApplication) {
|
||||||
|
app.buttons[A11yIdentifiers.appLockScreen.numpad(2)].tap()
|
||||||
|
app.buttons[A11yIdentifiers.appLockScreen.numpad(0)].tap()
|
||||||
|
app.buttons[A11yIdentifiers.appLockScreen.numpad(2)].tap()
|
||||||
|
app.buttons[A11yIdentifiers.appLockScreen.numpad(3)].tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enterWrongPIN(_ app: XCUIApplication) {
|
||||||
|
for _ in 0..<4 {
|
||||||
|
app.buttons[A11yIdentifiers.appLockScreen.numpad(0)].tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:86177b8a2db758a936a2a11a0f531bfb45511ca6c95b534d14946fd7543725e0
|
||||||
|
size 158276
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d73df700e470e40b07a5dfdcb250fec206c2ab5ace9203847a5b0f4126b89b82
|
||||||
|
size 190553
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:14451bd62aa2ac5ddee3eab2db4aee40d82f5a8b05950f4d03417ef315650774
|
||||||
|
size 111945
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a2abf3877cb363cae2d674fa3d11e059a62a60fc4a4e3eb8099f25c3187fe71e
|
||||||
|
size 115280
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2c8b2704da62a90ce7a558f8d2a4f8d42e7622c439976432dbbe00beeded4d28
|
||||||
|
size 164780
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d4f7231a3b4291a370d0c2507c522012f4edeb9878cd9fbb468f36c91d671e99
|
||||||
|
size 128890
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:14451bd62aa2ac5ddee3eab2db4aee40d82f5a8b05950f4d03417ef315650774
|
||||||
|
size 111945
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6678652d0e5a48dd7cfa197d52ff161cdcad75108c183a2748eb37099aedcda5
|
||||||
|
size 203737
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:aaee386193e4980a9ff49b4f06ab4f9885839153fadbca373b92182aff59e62e
|
||||||
|
size 123853
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d4f7231a3b4291a370d0c2507c522012f4edeb9878cd9fbb468f36c91d671e99
|
||||||
|
size 128890
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4fa4bb4e82f027e6a716ce28e49269718291b141f649bbd389dab45ecad16bc6
|
||||||
|
size 280901
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:32951bca5d167c6ce1523037cd31404f223b0ba2214247e68fedf6371f1d2d70
|
||||||
|
size 148086
|
||||||
@@ -43,6 +43,19 @@ final class AppLockScreenViewModelTests {
|
|||||||
#expect(result == .appUnlocked)
|
#expect(result == .appUnlocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func cancelVerifyDeviceOwner() async throws {
|
||||||
|
// Given a screen shown to verify the device owner.
|
||||||
|
viewModel = AppLockScreenViewModel(appLockService: appLockService, mode: .verifyDeviceOwner)
|
||||||
|
|
||||||
|
// When the user cancels.
|
||||||
|
let deferred = deferFulfillment(viewModel.actions) { $0 == .cancelVerifyDeviceOwner }
|
||||||
|
viewModel.context.send(viewAction: .cancelVerifyDeviceOwner)
|
||||||
|
|
||||||
|
// Then the cancellation should be reported.
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func forgotPIN() async throws {
|
func forgotPIN() async throws {
|
||||||
// Given a fresh launch of the app.
|
// Given a fresh launch of the app.
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ class LinkNewDeviceScreenViewModelTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func linkingProceedsWhenVerificationUnavailable() async throws {
|
func linkingWithoutDevicePasscodeOrAppLockPIN() async throws {
|
||||||
// Given a device with no passcode set, so device owner verification is unavailable.
|
// Given a device with neither a passcode nor an App Lock PIN set (device owner verification is unavailable).
|
||||||
let progressSubject = CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.starting)
|
let progressSubject = CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.starting)
|
||||||
setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(),
|
setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(),
|
||||||
deviceOwnerResult: .unavailable,
|
deviceOwnerResult: .unavailable,
|
||||||
@@ -109,20 +109,56 @@ class LinkNewDeviceScreenViewModelTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func appLockPINRequiredDoesNotLink() async throws {
|
func appLockPINRequiredRequestsVerification() async throws {
|
||||||
// Given a device with no passcode but an App Lock PIN set, so PIN verification is required.
|
// Given a device with no passcode but an App Lock PIN set, so PIN verification is required.
|
||||||
setupViewModel(deviceOwnerResult: .appLockPINRequired,
|
setupViewModel(deviceOwnerResult: .appLockPINRequired,
|
||||||
mode: .loading)
|
mode: .loading)
|
||||||
|
|
||||||
// When linking a mobile device, it should not proceed to generate a QR code.
|
// When linking a mobile device.
|
||||||
// TODO: assert the App Lock PIN screen is presented once it's wired up.
|
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0.isVerifyWithAppLockPIN }
|
||||||
let deferred = deferFailure(context.observe(\.viewState.mode), timeout: .seconds(1)) { $0 == .readyToLink(.generatingCode) }
|
|
||||||
context.send(viewAction: .linkMobileDevice)
|
context.send(viewAction: .linkMobileDevice)
|
||||||
try await deferred.fulfill()
|
let action = try await deferred.fulfill()
|
||||||
|
|
||||||
// Then verification should have been attempted but linking should not have started.
|
// Then it should request App Lock PIN verification rather than linking.
|
||||||
#expect(appLockService.verifyDeviceOwnerReasonCalled)
|
#expect(appLockService.verifyDeviceOwnerReasonCalled)
|
||||||
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
|
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
|
||||||
|
|
||||||
|
// When cancelling the verification.
|
||||||
|
guard case let .verifyWithAppLockPIN(continuation) = action else {
|
||||||
|
Issue.record("Unexpected action.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let deferredIdle = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) }
|
||||||
|
continuation.resume(returning: false)
|
||||||
|
|
||||||
|
// Then it should return to idle without linking.
|
||||||
|
try await deferredIdle.fulfill()
|
||||||
|
#expect(!linkNewDeviceService.linkMobileDeviceCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func appLockPINVerifiedContinuesLinking() async throws {
|
||||||
|
// Given a device that requires App Lock PIN verification.
|
||||||
|
let progressSubject = CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.starting)
|
||||||
|
setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(),
|
||||||
|
deviceOwnerResult: .appLockPINRequired,
|
||||||
|
mode: .loading)
|
||||||
|
|
||||||
|
// When linking a mobile device and the PIN verification succeeds.
|
||||||
|
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0.isVerifyWithAppLockPIN }
|
||||||
|
context.send(viewAction: .linkMobileDevice)
|
||||||
|
let action = try await deferred.fulfill()
|
||||||
|
guard case let .verifyWithAppLockPIN(continuation) = action else {
|
||||||
|
Issue.record("Unexpected action.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let deferredGenerating = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.generatingCode) }
|
||||||
|
continuation.resume(returning: true)
|
||||||
|
|
||||||
|
// Then it should proceed to generate the QR code.
|
||||||
|
try await deferredGenerating.fulfill()
|
||||||
|
#expect(linkNewDeviceService.linkMobileDeviceCalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Verification failures
|
// MARK: - Verification failures
|
||||||
@@ -259,4 +295,11 @@ private extension LinkNewDeviceScreenViewModelAction {
|
|||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isVerifyWithAppLockPIN: Bool {
|
||||||
|
switch self {
|
||||||
|
case .verifyWithAppLockPIN: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user