Add AppLockSetupPINScreen. (#1930)

This commit is contained in:
Doug
2023-10-23 10:07:13 +01:00
committed by GitHub
parent ceee6b1853
commit 5356a56a00
27 changed files with 811 additions and 7 deletions
@@ -60,6 +60,7 @@ struct AppLockScreen: View {
}
.font(.compound.bodyMDSemibold)
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.alert(item: $context.alertInfo)
}
@@ -0,0 +1,70 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct AppLockSetupPINScreenCoordinatorParameters {
/// Whether the screen should start in create or unlock mode.
/// Specifying confirm here will raise a fatal error.
let initialMode: AppLockSetupPINScreenMode
let appLockService: AppLockServiceProtocol
}
enum AppLockSetupPINScreenCoordinatorAction {
/// The user cancelled PIN entry.
case cancel
/// The user succeeded PIN entry.
case complete
}
final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
private let parameters: AppLockSetupPINScreenCoordinatorParameters
private var viewModel: AppLockSetupPINScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<AppLockSetupPINScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<AppLockSetupPINScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: AppLockSetupPINScreenCoordinatorParameters) {
guard parameters.initialMode != .confirm else { fatalError(".confirm is an invalid initial mode") }
self.parameters = parameters
viewModel = AppLockSetupPINScreenViewModel(initialMode: parameters.initialMode,
appLockService: parameters.appLockService)
}
func start() {
viewModel.actions.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .complete:
break
case .cancel:
break
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(AppLockSetupPINScreen(context: viewModel.context))
}
}
@@ -0,0 +1,76 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum AppLockSetupPINScreenViewModelAction {
/// The user succeeded PIN entry.
case complete
/// The user cancelled PIN entry.
case cancel
}
enum AppLockSetupPINScreenMode {
/// Creating a new PIN.
case create
/// Confirming the new PIN.
case confirm
/// Unlocking with the current PIN.
case unlock
}
struct AppLockSetupPINScreenViewState: BindableState {
/// The current mode that the screen is in.
var mode: AppLockSetupPINScreenMode
var title: String {
switch mode {
case .create: return L10n.screenAppLockSetupChoosePin
case .confirm: return L10n.screenAppLockSetupConfirmPin
case .unlock: return L10n.commonEnterYourPin
}
}
var subtitle: String? {
switch mode {
case .create, .confirm: return L10n.screenAppLockSetupPinContext(InfoPlistReader.main.bundleDisplayName)
case .unlock: return nil
}
}
var bindings: AppLockSetupPINScreenViewStateBindings
}
struct AppLockSetupPINScreenViewStateBindings {
var pinCode: String
var alertInfo: AlertInfo<AppLockSetupPINScreenAlertType>?
}
enum AppLockSetupPINScreenAlertType {
/// The user entered a weak PIN and it has been rejected.
case weakPIN
/// The user entered the wrong PIN when confirming the creation.
case pinMismatch
/// An error occurred setting the PIN code in the App Lock service.
case failedToSetPIN
}
enum AppLockSetupPINScreenViewAction {
/// Confirm the entered PIN.
case submitPINCode
/// Stop entering a PIN.
case cancel
}
@@ -0,0 +1,130 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
typealias AppLockSetupPINScreenViewModelType = StateStoreViewModel<AppLockSetupPINScreenViewState, AppLockSetupPINScreenViewAction>
class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLockSetupPINScreenViewModelProtocol {
private let appLockService: AppLockServiceProtocol
private var actionsSubject: PassthroughSubject<AppLockSetupPINScreenViewModelAction, Never> = .init()
/// The PIN entered by the user in `.create` mode, used for confirmation.
var newPIN: String?
var actions: AnyPublisher<AppLockSetupPINScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(initialMode: AppLockSetupPINScreenMode, appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, bindings: .init(pinCode: "")))
}
// MARK: - Public
override func process(viewAction: AppLockSetupPINScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .submitPINCode:
submitPINCode()
case .cancel:
actionsSubject.send(.cancel)
}
}
// MARK: - Private
/// Handle the entered PIN code.
private func submitPINCode() {
switch state.mode {
case .create:
createPIN()
case .confirm:
confirmPIN()
case .unlock:
unlock()
}
}
/// Handles a PIN input from the create mode. Transitions to confirmation if valid.
private func createPIN() {
let pinCode = state.bindings.pinCode
if case let .failure(error) = appLockService.validate(pinCode) {
MXLog.warning("PIN rejected: \(error)")
handleError(.weakPIN)
return
}
newPIN = pinCode
state.mode = .confirm
state.bindings.pinCode = ""
}
/// Handles a PIN input from the confirm mode. Stores the pin if it matches.
private func confirmPIN() {
let pinCode = state.bindings.pinCode
guard pinCode == newPIN else {
MXLog.warning("PIN mismatch.")
handleError(.pinMismatch)
return
}
if case let .failure(error) = appLockService.setupPINCode(pinCode) {
MXLog.warning("Failed to set PIN: \(error)")
if case .keychainError = error {
handleError(.failedToSetPIN)
return
} else {
handleError(.weakPIN) // Shouldn't really happen but just in case.
return
}
}
actionsSubject.send(.complete)
}
/// Handles a PIN input for the unlock mode.
private func unlock() {
guard appLockService.unlock(with: state.bindings.pinCode) else {
// show an error
// https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3?node-id=13067:107631&mode=dev#591068578
state.bindings.pinCode = ""
return
}
actionsSubject.send(.complete)
}
private func handleError(_ error: AppLockSetupPINScreenAlertType) {
switch error {
case .weakPIN:
state.bindings.alertInfo = .init(id: error,
title: L10n.screenAppLockSetupPinBlacklistedDialogTitle,
message: L10n.screenAppLockSetupPinBlacklistedDialogContent,
primaryButton: .init(title: L10n.actionOk) { self.state.bindings.pinCode = "" })
case .pinMismatch:
state.bindings.alertInfo = .init(id: error,
title: L10n.screenAppLockSetupPinMismatchDialogTitle,
message: L10n.screenAppLockSetupPinMismatchDialogContent,
primaryButton: .init(title: L10n.actionTryAgain) { self.state.bindings.pinCode = "" })
case .failedToSetPIN:
state.bindings.alertInfo = .init(id: error)
}
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol AppLockSetupPINScreenViewModelProtocol {
var actions: AnyPublisher<AppLockSetupPINScreenViewModelAction, Never> { get }
var context: AppLockSetupPINScreenViewModelType.Context { get }
}
@@ -0,0 +1,89 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
/// The screen shown to unlock the App Lock settings or to create a new PIN and enable the feature.
struct AppLockSetupPINScreen: View {
@ObservedObject var context: AppLockSetupPINScreenViewModel.Context
var body: some View {
ScrollView {
VStack(spacing: 48) {
header
PINTextField(pinCode: $context.pinCode,
isSecure: context.viewState.mode == .unlock)
.onChange(of: context.pinCode) { newValue in
guard newValue.count == 4 else { return }
context.send(viewAction: .submitPINCode)
}
}
.padding(.horizontal, 16)
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
.frame(maxWidth: .infinity)
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
}
var header: some View {
VStack(spacing: 8) {
HeroImage(image: Image(systemSymbol: .lock))
.symbolVariant(.fill)
.padding(.bottom, 8)
Text(context.viewState.title)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
if let subtitle = context.viewState.subtitle {
Text(subtitle)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
}
}
}
}
// MARK: - Previews
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 var previews: some View {
NavigationStack {
AppLockSetupPINScreen(context: createViewModel.context)
}
.previewDisplayName("Create")
NavigationStack {
AppLockSetupPINScreen(context: confirmViewModel.context)
}
.previewDisplayName("Confirm")
NavigationStack {
AppLockSetupPINScreen(context: unlockViewModel.context)
}
.previewDisplayName("Unlock")
}
}
@@ -0,0 +1,125 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
/// A text field that enables secure entry of a numerical PIN code.
/// The view itself handles validation and the base text field type.
struct PINTextField: View {
@Binding var pinCode: String
var isSecure = false
var body: some View {
textField
.textFieldStyle(PINTextFieldStyle(pinCode: pinCode, isSecure: isSecure))
.keyboardType(.numberPad)
.onChange(of: pinCode) { newValue in
let sanitized = sanitize(newValue)
if sanitized != newValue {
MXLog.warning("PIN code input sanitized.")
pinCode = sanitized
}
}
}
@ViewBuilder
var textField: some View {
if isSecure {
SecureField("", text: $pinCode)
} else {
TextField("", text: $pinCode)
}
}
func sanitize(_ pinCode: String) -> String {
var sanitized = pinCode
if sanitized.count > 4 { sanitized = String(pinCode.prefix(4)) }
return sanitized.filter(\.isNumber)
}
}
/// A text field style for displaying individual digits of a PIN code.
private struct PINTextFieldStyle: TextFieldStyle {
@FocusState private var isFocussed
let pinCode: String
let isSecure: Bool
public func _body(configuration: TextField<_Label>) -> some View {
HStack(spacing: 8) {
ForEach(0..<4) { index in
PINDigitField(digit: digit(index))
}
}
.overlay {
configuration
.focused($isFocussed)
.opacity(0.0)
}
.onTapGesture { isFocussed = true }
}
func digit(_ index: Int) -> Character? {
guard pinCode.count > index else { return nil }
let stringIndex = pinCode.index(pinCode.startIndex, offsetBy: index)
return isSecure ? "●" : pinCode[stringIndex]
}
}
/// A single digit shown within the text field style.
private struct PINDigitField: View {
let digit: Character?
var body: some View {
ZStack {
if let digit {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.compound.bgSubtlePrimary)
Text(String(digit))
.font(.compound.headingMDBold)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.inset(by: 0.5)
.stroke(Color.compound.iconPrimary, lineWidth: 1)
}
}
.frame(width: 48, height: 48)
}
}
struct PINTextField_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
PreviewWrapper(pinCode: "", isSecure: false)
PreviewWrapper(pinCode: "12", isSecure: false)
PreviewWrapper(pinCode: "1234", isSecure: false)
.padding(.bottom)
PreviewWrapper(pinCode: "", isSecure: true)
PreviewWrapper(pinCode: "12", isSecure: true)
PreviewWrapper(pinCode: "1234", isSecure: true)
}
}
struct PreviewWrapper: View {
@State var pinCode = ""
let isSecure: Bool
var body: some View {
PINTextField(pinCode: $pinCode, isSecure: isSecure)
}
}
}