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:
Doug
2026-07-01 10:13:18 +01:00
committed by GitHub
parent 7097ceb910
commit 123661537a
38 changed files with 296 additions and 21 deletions
@@ -12,6 +12,8 @@ import SwiftUI
struct AppLockScreenCoordinatorParameters {
/// The service used to unlock the app.
let appLockService: AppLockServiceProtocol
/// The context in which the screen is being shown.
var mode: AppLockScreenMode = .appUnlock
}
enum AppLockScreenCoordinatorAction {
@@ -19,6 +21,8 @@ enum AppLockScreenCoordinatorAction {
case appUnlocked
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
/// The user cancelled device owner verification. Only sent in the `.verifyDeviceOwner` mode.
case cancelVerifyDeviceOwner
}
final class AppLockScreenCoordinator: CoordinatorProtocol {
@@ -31,7 +35,7 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
}
init(parameters: AppLockScreenCoordinatorParameters) {
viewModel = AppLockScreenViewModel(appLockService: parameters.appLockService)
viewModel = AppLockScreenViewModel(appLockService: parameters.appLockService, mode: parameters.mode)
}
func start() {
@@ -44,6 +48,8 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
self.actionsSubject.send(.appUnlocked)
case .forceLogout:
self.actionsSubject.send(.forceLogout)
case .cancelVerifyDeviceOwner:
self.actionsSubject.send(.cancelVerifyDeviceOwner)
}
}
.store(in: &cancellables)
@@ -13,9 +13,21 @@ enum AppLockScreenViewModelAction {
case appUnlocked
/// The user failed to unlock the app (or forgot their PIN).
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 {
/// The context in which the screen is being shown.
var mode: AppLockScreenMode = .appUnlock
/// The number of attempts allowed to unlock the app.
let maximumAttempts = 3
@@ -64,4 +76,6 @@ enum AppLockScreenViewAction {
case clearPINCode
/// The user didn't heed the warnings and can't remember their PIN.
case forgotPIN
/// The user cancelled device owner verification.
case cancelVerifyDeviceOwner
}
@@ -19,10 +19,10 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
actionsSubject.eraseToAnyPublisher()
}
init(appLockService: AppLockServiceProtocol) {
init(appLockService: AppLockServiceProtocol, mode: AppLockScreenMode = .appUnlock) {
self.appLockService = appLockService
super.init(initialViewState: AppLockScreenViewState(bindings: .init()))
super.init(initialViewState: AppLockScreenViewState(mode: mode, bindings: .init()))
appLockService.numberOfPINAttempts
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
@@ -51,6 +51,8 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
state.bindings.pinCode = ""
case .forgotPIN:
handleForgotPIN()
case .cancelVerifyDeviceOwner:
actionsSubject.send(.cancelVerifyDeviceOwner)
}
}
@@ -54,6 +54,19 @@ struct AppLockScreen: View {
.animation(.elementDefault, value: context.viewState.forcedLogoutIndicator)
}
.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 {
@@ -114,10 +127,17 @@ struct AppLockScreen: View {
struct AppLockScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = AppLockScreenViewModel(appLockService: AppLockServiceMock.mock())
static let verifyViewModel = AppLockScreenViewModel(appLockService: AppLockServiceMock.mock(), mode: .verifyDeviceOwner)
static var previews: some View {
ElementNavigationStack {
AppLockScreen(context: viewModel.context)
}
.previewDisplayName("Unlock")
ElementNavigationStack {
AppLockScreen(context: verifyViewModel.context)
}
.previewDisplayName("Verify")
}
}
@@ -11,6 +11,7 @@ import SwiftUI
enum LinkNewDeviceScreenCoordinatorAction {
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkDesktopComputer
case verifyWithAppLockPIN(CheckedContinuation<Bool, Never>)
case dismiss
}
@@ -47,6 +48,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.linkMobileDevice(progressPublisher))
case .linkDesktopComputer:
actionsSubject.send(.linkDesktopComputer)
case .verifyWithAppLockPIN(let continuation):
actionsSubject.send(.verifyWithAppLockPIN(continuation))
case .dismiss:
actionsSubject.send(.dismiss)
}
@@ -10,6 +10,7 @@ import Foundation
enum LinkNewDeviceScreenViewModelAction {
case linkMobileDevice(LinkNewDeviceService.LinkMobileProgressPublisher)
case linkDesktopComputer
case verifyWithAppLockPIN(CheckedContinuation<Bool, Never>)
case dismiss
}
@@ -72,8 +72,10 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
case .verified, .unavailable:
break
case .appLockPINRequired:
// Follow-up PR: present the App Lock PIN screen to verify the device owner.
return
guard await verifyWithAppLockPIN() else {
state.mode = .readyToLink(.idle)
return
}
case .cancelled, .unverified:
state.mode = .readyToLink(.idle)
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 {
state.mode = .readyToLink(.generatingCode)