Initial implementation for AppLockSetupFlowCoordinator. (#1949)

Rename AppLockSettingsScreen to AppLockSetupSettingsScreen.
Implement forced logout and persist PIN entry attempts.
This commit is contained in:
Doug
2023-10-25 12:03:37 +01:00
committed by GitHub
parent 30b18031a0
commit c6e4dc2a37
53 changed files with 773 additions and 234 deletions
@@ -25,6 +25,8 @@ struct AppLockScreenCoordinatorParameters {
enum AppLockScreenCoordinatorAction {
/// The user has successfully unlocked the app.
case appUnlocked
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
}
final class AppLockScreenCoordinator: CoordinatorProtocol {
@@ -51,6 +53,8 @@ final class AppLockScreenCoordinator: CoordinatorProtocol {
switch action {
case .appUnlocked:
self.actionsSubject.send(.appUnlocked)
case .forceLogout:
self.actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
@@ -19,6 +19,8 @@ import Foundation
enum AppLockScreenViewModelAction {
/// The user has successfully unlocked the app.
case appUnlocked
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
}
struct AppLockScreenViewState: BindableState {
@@ -36,7 +38,7 @@ struct AppLockScreenViewState: BindableState {
/// The string shown in the screen's subtitle.
var subtitle: String {
if !isSubtitleWarning {
return L10n.screenAppLockSubtitle
return L10n.screenAppLockSubtitle(maximumAttempts)
} else {
return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfPINAttempts)
}
@@ -50,8 +52,8 @@ struct AppLockScreenViewStateBindings {
}
enum AppLockScreenAlertType {
/// The user has failed too many times, they're being signed out.
case forceSignOut
/// The user has failed too many times, they're being logged out.
case forcedLogout
/// The user has forgotten their PIN, confirm they're happy to sign out.
case confirmResetPIN
}
@@ -31,6 +31,12 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
self.appLockService = appLockService
super.init(initialViewState: AppLockScreenViewState(bindings: .init()))
appLockService.numberOfPINAttempts
.weakAssign(to: \.state.numberOfPINAttempts, on: self)
.store(in: &cancellables)
showForceLogoutAlertIfNeeded()
}
// MARK: - Public
@@ -58,24 +64,21 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
state.bindings.alertInfo = .init(id: .confirmResetPIN,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk, action: forceSignOut),
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private func handleInvalidPIN() {
MXLog.warning("Invalid PIN code entered.")
state.numberOfPINAttempts += 1
if state.numberOfPINAttempts == 3 {
state.bindings.alertInfo = .init(id: .forceSignOut,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk, action: nil))
forceSignOut()
}
showForceLogoutAlertIfNeeded()
}
private func forceSignOut() {
// To be implemented.
private func showForceLogoutAlertIfNeeded() {
if state.numberOfPINAttempts >= 3 {
state.bindings.alertInfo = .init(id: .forcedLogout,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) })
}
}
}
@@ -31,8 +31,10 @@ struct AppLockSetupBiometricsScreen: View {
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
.frame(maxWidth: .infinity)
}
.toolbar(.visible, for: .navigationBar)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.toolbar(.visible, for: .navigationBar)
.navigationBarBackButtonHidden()
.interactiveDismissDisabled()
.safeAreaInset(edge: .bottom) {
buttons
.padding(.top, 16)
@@ -21,6 +21,8 @@ struct AppLockSetupPINScreenCoordinatorParameters {
/// Whether the screen should start in create or unlock mode.
/// Specifying confirm here will raise a fatal error.
let initialMode: AppLockSetupPINScreenMode
/// Whether the screen is mandatory or can be cancelled by the user.
let isMandatory: Bool
let appLockService: AppLockServiceProtocol
}
@@ -46,6 +48,7 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = AppLockSetupPINScreenViewModel(initialMode: parameters.initialMode,
isMandatory: parameters.isMandatory,
appLockService: parameters.appLockService)
}
@@ -56,9 +59,9 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .complete:
break
actionsSubject.send(.complete)
case .cancel:
break
actionsSubject.send(.cancel)
}
}
.store(in: &cancellables)
@@ -35,6 +35,8 @@ enum AppLockSetupPINScreenMode {
struct AppLockSetupPINScreenViewState: BindableState {
/// The current mode that the screen is in.
var mode: AppLockSetupPINScreenMode
/// Whether the screen is mandatory or can be cancelled.
let isMandatory: Bool
var title: String {
switch mode {
@@ -30,9 +30,9 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
actionsSubject.eraseToAnyPublisher()
}
init(initialMode: AppLockSetupPINScreenMode, appLockService: AppLockServiceProtocol) {
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, bindings: .init(pinCode: "")))
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
}
// MARK: - Public
@@ -21,6 +21,8 @@ import SwiftUI
struct AppLockSetupPINScreen: View {
@ObservedObject var context: AppLockSetupPINScreenViewModel.Context
@FocusState private var textFieldFocus
var body: some View {
ScrollView {
VStack(spacing: 48) {
@@ -28,6 +30,7 @@ struct AppLockSetupPINScreen: View {
PINTextField(pinCode: $context.pinCode,
isSecure: context.viewState.mode == .unlock)
.focused($textFieldFocus)
.onChange(of: context.pinCode) { newValue in
guard newValue.count == 4 else { return }
context.send(viewAction: .submitPINCode)
@@ -38,6 +41,12 @@ struct AppLockSetupPINScreen: View {
.frame(maxWidth: .infinity)
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.toolbar { toolbar }
.toolbar(.visible, for: .navigationBar)
.navigationBarBackButtonHidden()
.interactiveDismissDisabled(context.viewState.isMandatory)
.alert(item: $context.alertInfo)
.onAppear { textFieldFocus = true }
}
var header: some View {
@@ -59,6 +68,17 @@ struct AppLockSetupPINScreen: View {
}
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
if !context.viewState.isMandatory {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
}
// MARK: - Previews
@@ -66,9 +86,15 @@ struct AppLockSetupPINScreen: View {
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
static let service = AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings)
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: service)
static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm, appLockService: service)
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, appLockService: service)
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
isMandatory: false,
appLockService: service)
static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm,
isMandatory: false,
appLockService: service)
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
isMandatory: false,
appLockService: service)
static var previews: some View {
NavigationStack {
@@ -17,28 +17,31 @@
import Combine
import SwiftUI
struct AppLockSettingsScreenCoordinatorParameters {
struct AppLockSetupSettingsScreenCoordinatorParameters {
/// Whether App Lock is mandatory and can be disabled by the user.
let isMandatory: Bool
let appLockService: AppLockServiceProtocol
}
enum AppLockSettingsScreenCoordinatorAction {
case done
enum AppLockSetupSettingsScreenCoordinatorAction {
case changePINCode
case appLockDisabled
}
final class AppLockSettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: AppLockSettingsScreenCoordinatorParameters
private var viewModel: AppLockSettingsScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<AppLockSettingsScreenCoordinatorAction, Never> = .init()
final class AppLockSetupSettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: AppLockSetupSettingsScreenCoordinatorParameters
private var viewModel: AppLockSetupSettingsScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<AppLockSetupSettingsScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<AppLockSettingsScreenCoordinatorAction, Never> {
var actions: AnyPublisher<AppLockSetupSettingsScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: AppLockSettingsScreenCoordinatorParameters) {
init(parameters: AppLockSetupSettingsScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = AppLockSettingsScreenViewModel(appLockService: parameters.appLockService)
viewModel = AppLockSetupSettingsScreenViewModel(appLockService: parameters.appLockService)
}
func start() {
@@ -48,15 +51,15 @@ final class AppLockSettingsScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .changePINCode:
break
case .done:
self.actionsSubject.send(.done)
actionsSubject.send(.changePINCode)
case .appLockDisabled:
actionsSubject.send(.appLockDisabled)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(AppLockSettingsScreen(context: viewModel.context))
AnyView(AppLockSetupSettingsScreen(context: viewModel.context))
}
}
@@ -17,36 +17,38 @@
import Foundation
import LocalAuthentication
enum AppLockSettingsScreenViewModelAction {
enum AppLockSetupSettingsScreenViewModelAction {
/// The user would like to enter new PIN code.
case changePINCode
case done
/// The user has disabled the App Lock feature.
case appLockDisabled
}
struct AppLockSettingsScreenViewState: BindableState {
struct AppLockSetupSettingsScreenViewState: BindableState {
/// Whether App Lock is mandatory and can be disabled by the user.
let isMandatory: Bool
let biometryType: LABiometryType
var bindings: AppLockSettingsScreenViewStateBindings
var bindings: AppLockSetupSettingsScreenViewStateBindings
var supportsBiometry: Bool { biometryType != .none }
var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
}
struct AppLockSettingsScreenViewStateBindings {
struct AppLockSetupSettingsScreenViewStateBindings {
var enableBiometrics: Bool
var alertInfo: AlertInfo<AppLockSettingsScreenAlertType>?
var alertInfo: AlertInfo<AppLockSetupSettingsScreenAlertType>?
}
enum AppLockSettingsScreenAlertType {
enum AppLockSetupSettingsScreenAlertType {
/// The alert shown to confirm the user would like to remove their PIN.
case confirmRemovePINCode
}
enum AppLockSettingsScreenViewAction {
enum AppLockSetupSettingsScreenViewAction {
/// The user would like to enter a new PIN code.
case changePINCode
/// The user would like to disable the App Lock feature.
case disable
/// The user has toggled the biometrics setting.
case enableBiometricsChanged
case done
}
@@ -17,25 +17,26 @@
import Combine
import SwiftUI
typealias AppLockSettingsScreenViewModelType = StateStoreViewModel<AppLockSettingsScreenViewState, AppLockSettingsScreenViewAction>
typealias AppLockSetupSettingsScreenViewModelType = StateStoreViewModel<AppLockSetupSettingsScreenViewState, AppLockSetupSettingsScreenViewAction>
class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLockSettingsScreenViewModelProtocol {
class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelType, AppLockSetupSettingsScreenViewModelProtocol {
private let appLockService: AppLockServiceProtocol
private var actionsSubject: PassthroughSubject<AppLockSettingsScreenViewModelAction, Never> = .init()
private var actionsSubject: PassthroughSubject<AppLockSetupSettingsScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<AppLockSettingsScreenViewModelAction, Never> {
var actions: AnyPublisher<AppLockSetupSettingsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService
super.init(initialViewState: AppLockSettingsScreenViewState(biometryType: appLockService.biometryType,
bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled)))
super.init(initialViewState: AppLockSetupSettingsScreenViewState(isMandatory: appLockService.isMandatory,
biometryType: appLockService.biometryType,
bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled)))
}
// MARK: - Public
override func process(viewAction: AppLockSettingsScreenViewAction) {
override func process(viewAction: AppLockSetupSettingsScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
@@ -45,8 +46,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc
showRemovePINAlert()
case .enableBiometricsChanged:
appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics
case .done:
actionsSubject.send(.done)
}
}
@@ -64,6 +63,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc
/// Removes the user's PIN code, disabling the App Lock feature.
private func completeRemovePIN() {
appLockService.disable()
actionsSubject.send(.done)
actionsSubject.send(.appLockDisabled)
}
}
@@ -17,7 +17,7 @@
import Combine
@MainActor
protocol AppLockSettingsScreenViewModelProtocol {
var actions: AnyPublisher<AppLockSettingsScreenViewModelAction, Never> { get }
var context: AppLockSettingsScreenViewModelType.Context { get }
protocol AppLockSetupSettingsScreenViewModelProtocol {
var actions: AnyPublisher<AppLockSetupSettingsScreenViewModelAction, Never> { get }
var context: AppLockSetupSettingsScreenViewModelType.Context { get }
}
@@ -17,16 +17,19 @@
import Compound
import SwiftUI
struct AppLockSettingsScreen: View {
@ObservedObject var context: AppLockSettingsScreenViewModel.Context
struct AppLockSetupSettingsScreen: View {
@ObservedObject var context: AppLockSetupSettingsScreenViewModel.Context
var body: some View {
Form {
Section {
ListRow(label: .plain(title: L10n.screenAppLockSettingsChangePin),
kind: .button { context.send(viewAction: .changePINCode) })
ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive),
kind: .button { context.send(viewAction: .disable) })
if !context.viewState.isMandatory {
ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive),
kind: .button { context.send(viewAction: .disable) })
}
}
if context.viewState.supportsBiometry {
@@ -48,25 +51,25 @@ struct AppLockSettingsScreen: View {
// MARK: - Previews
struct AppLockSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let faceIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID))
static let touchIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .touchID))
static let biometricsUnavailableViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none))
struct AppLockSetupSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let faceIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID))
static let touchIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(isMandatory: true, biometryType: .touchID))
static let biometricsUnavailableViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none))
static var previews: some View {
NavigationStack {
AppLockSettingsScreen(context: faceIDViewModel.context)
AppLockSetupSettingsScreen(context: faceIDViewModel.context)
}
.previewDisplayName("Face ID")
NavigationStack {
AppLockSettingsScreen(context: touchIDViewModel.context)
AppLockSetupSettingsScreen(context: touchIDViewModel.context)
}
.previewDisplayName("Touch ID")
.previewDisplayName("Touch ID (Mandatory)")
NavigationStack {
AppLockSettingsScreen(context: biometricsUnavailableViewModel.context)
AppLockSetupSettingsScreen(context: biometricsUnavailableViewModel.context)
}
.previewDisplayName("No Biometrics")
.previewDisplayName("PIN only")
}
}