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
+20
View File
@@ -174,6 +174,8 @@ public enum L10n {
public static var actionStaticMapLoad: String { return L10n.tr("Localizable", "action_static_map_load") }
/// Take photo
public static var actionTakePhoto: String { return L10n.tr("Localizable", "action_take_photo") }
/// Try again
public static var actionTryAgain: String { return L10n.tr("Localizable", "action_try_again") }
/// View source
public static var actionViewSource: String { return L10n.tr("Localizable", "action_view_source") }
/// Yes
@@ -664,6 +666,24 @@ public enum L10n {
public static var screenAppLockSettingsRemovePinAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_settings_remove_pin_alert_message") }
/// Remove PIN?
public static var screenAppLockSettingsRemovePinAlertTitle: String { return L10n.tr("Localizable", "screen_app_lock_settings_remove_pin_alert_title") }
/// Choose PIN
public static var screenAppLockSetupChoosePin: String { return L10n.tr("Localizable", "screen_app_lock_setup_choose_pin") }
/// Confirm PIN
public static var screenAppLockSetupConfirmPin: String { return L10n.tr("Localizable", "screen_app_lock_setup_confirm_pin") }
/// You cannot choose this as your PIN code for security reasons
public static var screenAppLockSetupPinBlacklistedDialogContent: String { return L10n.tr("Localizable", "screen_app_lock_setup_pin_blacklisted_dialog_content") }
/// Choose a different PIN
public static var screenAppLockSetupPinBlacklistedDialogTitle: String { return L10n.tr("Localizable", "screen_app_lock_setup_pin_blacklisted_dialog_title") }
/// Lock %1$@ to add extra security to your chats.
///
/// Choose something memorable. If you forget this PIN, you will be logged out of the app.
public static func screenAppLockSetupPinContext(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_app_lock_setup_pin_context", String(describing: p1))
}
/// Please enter the same PIN twice
public static var screenAppLockSetupPinMismatchDialogContent: String { return L10n.tr("Localizable", "screen_app_lock_setup_pin_mismatch_dialog_content") }
/// PINs don't match
public static var screenAppLockSetupPinMismatchDialogTitle: String { return L10n.tr("Localizable", "screen_app_lock_setup_pin_mismatch_dialog_title") }
/// You’ll need to re-login and create a new PIN to proceed
public static var screenAppLockSignoutAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_message") }
/// You are being signed out
@@ -154,6 +154,27 @@ class AppLockServiceMock: AppLockServiceProtocol {
return setupPINCodeReturnValue
}
}
//MARK: - validate
var validateCallsCount = 0
var validateCalled: Bool {
return validateCallsCount > 0
}
var validateReceivedPinCode: String?
var validateReceivedInvocations: [String] = []
var validateReturnValue: Result<Void, AppLockServiceError>!
var validateClosure: ((String) -> Result<Void, AppLockServiceError>)?
func validate(_ pinCode: String) -> Result<Void, AppLockServiceError> {
validateCallsCount += 1
validateReceivedPinCode = pinCode
validateReceivedInvocations.append(pinCode)
if let validateClosure = validateClosure {
return validateClosure(pinCode)
} else {
return validateReturnValue
}
}
//MARK: - disable
var disableCallsCount = 0
@@ -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)
}
}
}
@@ -47,8 +47,8 @@ class AppLockService: AppLockServiceProtocol {
}
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> {
guard validate(pinCode) else { return .failure(.invalidPIN) }
guard !appSettings.appLockPINCodeBlockList.contains(pinCode) else { return .failure(.weakPIN) }
let result = validate(pinCode)
guard case .success = result else { return result }
do {
try keychainController.setPINCode(pinCode)
@@ -59,6 +59,12 @@ class AppLockService: AppLockServiceProtocol {
}
}
func validate(_ pinCode: String) -> Result<Void, AppLockServiceError> {
guard pinCode.count == 4, pinCode.allSatisfy(\.isNumber) else { return .failure(.invalidPIN) }
guard !appSettings.appLockPINCodeBlockList.contains(pinCode) else { return .failure(.weakPIN) }
return .success(())
}
func disable() {
biometricUnlockEnabled = false
keychainController.removePINCode()
@@ -94,11 +100,6 @@ class AppLockService: AppLockServiceProtocol {
}
}
/// Ensures that a provided PIN code is long enough and only contains digits.
private func validate(_ pinCode: String) -> Bool {
pinCode.count == 4 && pinCode.allSatisfy(\.isNumber)
}
/// Shared logic for completing an unlock via a PIN or biometry.
private func completeUnlock() -> Bool {
timer.registerUnlock()
@@ -36,6 +36,8 @@ protocol AppLockServiceProtocol: AnyObject {
/// Sets the user's PIN code used to unlock the app.
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError>
/// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice.
func validate(_ pinCode: String) -> Result<Void, AppLockServiceError>
/// Disables the App Lock feature, removing the user's stored PIN code.
func disable()
@@ -161,6 +161,13 @@ class MockScreen: Identifiable {
let appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings)
let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService))
return coordinator
case .appLockSetupFlow:
// Use the flow coordinator once more screens are added and remove the settings screen below.
let navigationStackCoordinator = NavigationStackCoordinator()
let appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings)
let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .create, appLockService: appLockService))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .appLockSettingsScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let appLockService = AppLockServiceMock.mock(biometryType: .faceID)
@@ -30,6 +30,7 @@ enum UITestsScreenIdentifier: String {
case migration
case templateScreen
case appLockScreen
case appLockSetupFlow
case appLockSettingsScreen
case home
case settings