mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Initial implementation for AppLockSetupFlowCoordinator. (#1949)
Rename AppLockSettingsScreen to AppLockSetupSettingsScreen. Implement forced logout and persist PIN entry attempts.
This commit is contained in:
@@ -102,8 +102,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
backgroundTaskService: backgroundTaskService)
|
||||
|
||||
let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings)
|
||||
let appLockNavigationCoordinator = NavigationRootCoordinator()
|
||||
let appLockFlowUserIndicatorController = UserIndicatorController(rootCoordinator: appLockNavigationCoordinator)
|
||||
appLockFlowCoordinator = AppLockFlowCoordinator(appLockService: appLockService,
|
||||
navigationCoordinator: NavigationRootCoordinator())
|
||||
userIndicatorController: appLockFlowUserIndicatorController,
|
||||
navigationCoordinator: appLockNavigationCoordinator)
|
||||
|
||||
notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(),
|
||||
appSettings: appSettings)
|
||||
@@ -329,12 +332,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
// We can ignore signOut when already in the process of signing out,
|
||||
// such as the SDK sending an authError due to token invalidation.
|
||||
break
|
||||
case (_, .signOut(let isSoft), .signingOut):
|
||||
case (_, .signOut(let isSoft, _), .signingOut):
|
||||
self.logout(isSoft: isSoft)
|
||||
case (.signingOut, .completedSigningOut, .signedOut):
|
||||
self.presentSplashScreen(isSoftLogout: false)
|
||||
case (.signingOut, .showSoftLogout, .softLogout):
|
||||
self.presentSplashScreen(isSoftLogout: true)
|
||||
case (.signingOut(_, let disableAppLock), .completedSigningOut, .signedOut):
|
||||
self.presentSplashScreen(isSoftLogout: false, disableAppLock: disableAppLock)
|
||||
case (.signingOut(_, let disableAppLock), .showSoftLogout, .softLogout):
|
||||
self.presentSplashScreen(isSoftLogout: true, disableAppLock: disableAppLock)
|
||||
case (.signedIn, .clearCache, .initial):
|
||||
self.clearCache()
|
||||
default:
|
||||
@@ -371,7 +374,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
navigationStackCoordinator: authenticationNavigationStackCoordinator,
|
||||
appSettings: appSettings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appLockService: appLockFlowCoordinator.appLockService)
|
||||
authenticationCoordinator?.delegate = self
|
||||
|
||||
authenticationCoordinator?.start()
|
||||
@@ -410,7 +414,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
stateMachine.processEvent(.createdUserSession)
|
||||
case .clearAllData:
|
||||
self.softLogoutCoordinator = nil
|
||||
stateMachine.processEvent(.signOut(isSoft: false))
|
||||
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -439,7 +443,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
|
||||
switch action {
|
||||
case .logout:
|
||||
stateMachine.processEvent(.signOut(isSoft: false))
|
||||
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
|
||||
case .clearCache:
|
||||
stateMachine.processEvent(.clearCache)
|
||||
}
|
||||
@@ -512,7 +516,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
notificationManager.setUserSession(nil)
|
||||
}
|
||||
|
||||
private func presentSplashScreen(isSoftLogout: Bool = false) {
|
||||
private func presentSplashScreen(isSoftLogout: Bool = false, disableAppLock: Bool = false) {
|
||||
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
|
||||
|
||||
if isSoftLogout {
|
||||
@@ -520,6 +524,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
} else {
|
||||
startAuthentication()
|
||||
}
|
||||
|
||||
if disableAppLock {
|
||||
Task {
|
||||
// Ensure the navigation stack has settled.
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
appLockFlowCoordinator.appLockService.disable()
|
||||
windowManager.switchToMain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureNotificationManager() {
|
||||
@@ -549,7 +562,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
guard let self else { return }
|
||||
switch callback {
|
||||
case .didReceiveAuthError(let isSoftLogout):
|
||||
stateMachine.processEvent(.signOut(isSoft: isSoftLogout))
|
||||
stateMachine.processEvent(.signOut(isSoft: isSoftLogout, disableAppLock: false))
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -583,6 +596,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
windowManager.switchToAlternate()
|
||||
case .unlockApp:
|
||||
windowManager.switchToMain()
|
||||
case .forceLogout:
|
||||
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -33,7 +33,7 @@ class AppCoordinatorStateMachine {
|
||||
case signedIn
|
||||
|
||||
/// Processing a sign out request
|
||||
case signingOut(isSoft: Bool)
|
||||
case signingOut(isSoft: Bool, disableAppLock: Bool)
|
||||
}
|
||||
|
||||
/// Events that can be triggered on the AppCoordinator state machine
|
||||
@@ -51,7 +51,7 @@ class AppCoordinatorStateMachine {
|
||||
case createdUserSession
|
||||
|
||||
/// Request sign out.
|
||||
case signOut(isSoft: Bool)
|
||||
case signOut(isSoft: Bool, disableAppLock: Bool)
|
||||
/// Request the soft logout screen.
|
||||
case showSoftLogout
|
||||
/// Signing out completed.
|
||||
@@ -80,16 +80,17 @@ class AppCoordinatorStateMachine {
|
||||
stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn])
|
||||
stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])
|
||||
|
||||
stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false) => .signedOut])
|
||||
stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true) => .softLogout])
|
||||
stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false, disableAppLock: false) => .signedOut,
|
||||
.signingOut(isSoft: false, disableAppLock: true) => .signedOut])
|
||||
stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true, disableAppLock: false) => .softLogout])
|
||||
|
||||
stateMachine.addRoutes(event: .clearCache, transitions: [.signedIn => .initial])
|
||||
|
||||
// Transitions with associated values need to be handled through `addRouteMapping`
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
switch (event, fromState) {
|
||||
case (.signOut(let isSoft), _):
|
||||
return .signingOut(isSoft: isSoft)
|
||||
case (.signOut(let isSoft, let disableAppLock), _):
|
||||
return .signingOut(isSoft: isSoft, disableAppLock: disableAppLock)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ final class AppSettings {
|
||||
private enum UserDefaultsKeys: String {
|
||||
case lastVersionLaunched
|
||||
case seenInvites
|
||||
case hasShownWelcomeScreen
|
||||
case appLockNumberOfPINAttempts
|
||||
case appLockNumberOfBiometricAttempts
|
||||
case lastLoginDate
|
||||
case migratedAccounts
|
||||
case timelineStyle
|
||||
@@ -38,7 +41,6 @@ final class AppSettings {
|
||||
case shouldCollapseRoomStateEvents
|
||||
case userSuggestionsEnabled
|
||||
case readReceiptsEnabled
|
||||
case hasShownWelcomeScreen
|
||||
case swiftUITimelineEnabled
|
||||
case voiceMessageEnabled
|
||||
case mentionsEnabled
|
||||
@@ -122,10 +124,18 @@ final class AppSettings {
|
||||
|
||||
// MARK: - Security
|
||||
|
||||
/// The app must be locked with a PIN code as part of the authentication flow.
|
||||
let appLockIsMandatory = false
|
||||
/// The amount of time the app can remain in the background for without requesting the PIN/TouchID/FaceID.
|
||||
let appLockGracePeriod: TimeInterval = 180
|
||||
/// Any codes that the user isn't allowed to use for their PIN.
|
||||
let appLockPINCodeBlockList = ["0000", "1234"]
|
||||
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked).
|
||||
@UserPreference(key: UserDefaultsKeys.appLockNumberOfPINAttempts, defaultValue: 0, storageType: .userDefaults(store))
|
||||
var appLockNumberOfPINAttempts: Int
|
||||
/// The number of attempts the user has made to unlock the app with Touch/Face ID (resets when unlocked).
|
||||
@UserPreference(key: UserDefaultsKeys.appLockNumberOfBiometricAttempts, defaultValue: 0, storageType: .userDefaults(store))
|
||||
var appLockNumberOfBiometricAttempts: Int
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
|
||||
@@ -22,11 +22,14 @@ enum AppLockFlowCoordinatorAction: Equatable {
|
||||
case lockApp
|
||||
/// Hide the unlock flow.
|
||||
case unlockApp
|
||||
/// Forces a logout of the user.
|
||||
case forceLogout
|
||||
}
|
||||
|
||||
/// Coordinates the display of any screens shown when the app is locked.
|
||||
class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
let appLockService: AppLockServiceProtocol
|
||||
let userIndicatorController: UserIndicatorController
|
||||
let navigationCoordinator: NavigationRootCoordinator
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
@@ -36,10 +39,18 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(appLockService: AppLockServiceProtocol, navigationCoordinator: NavigationRootCoordinator) {
|
||||
init(appLockService: AppLockServiceProtocol, userIndicatorController: UserIndicatorController, navigationCoordinator: NavigationRootCoordinator) {
|
||||
self.appLockService = appLockService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.navigationCoordinator = navigationCoordinator
|
||||
|
||||
appLockService.disabledPublisher
|
||||
.sink { [weak self] _ in
|
||||
// When the service is disabled via a force logout, we need to remove the activity indicator.
|
||||
self?.userIndicatorController.retractAllIndicators()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.applicationDidEnterBackground()
|
||||
@@ -54,7 +65,7 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(navigationCoordinator.toPresentable())
|
||||
AnyView(userIndicatorController.toPresentable())
|
||||
}
|
||||
|
||||
// MARK: - App unlock
|
||||
@@ -91,6 +102,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .appUnlocked:
|
||||
actionsSubject.send(.unlockApp)
|
||||
case .forceLogout:
|
||||
userIndicatorController.submitIndicator(UserIndicator(type: .modal, title: L10n.commonSigningOut, persistent: true))
|
||||
actionsSubject.send(.forceLogout)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import SwiftState
|
||||
import SwiftUI
|
||||
|
||||
enum AppLockSetupFlowCoordinatorAction: Equatable {
|
||||
case complete
|
||||
}
|
||||
|
||||
/// Coordinates the display of any screens used to configure the App Lock feature.
|
||||
class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let presentingFlow: PresentationFlow
|
||||
private let appLockService: AppLockServiceProtocol
|
||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||
private let modalNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
/// The presentation context of the flow.
|
||||
enum PresentationFlow {
|
||||
/// The flow is shown as for mandatory PIN creation in the authentication flow
|
||||
case authentication
|
||||
/// The flow is shown from the Settings screen.
|
||||
case settings
|
||||
}
|
||||
|
||||
/// States the flow can find itself in
|
||||
enum State: StateType {
|
||||
/// The initial state, used before the flow starts
|
||||
case initial
|
||||
/// The unlock screen.
|
||||
case unlock
|
||||
/// The create PIN screen.
|
||||
case createPIN
|
||||
/// The allow biometrics screen.
|
||||
case biometricsPrompt
|
||||
/// The settings screen.
|
||||
case settings
|
||||
/// The flow is finished.
|
||||
case complete
|
||||
}
|
||||
|
||||
/// Events that can be triggered on the flow state machine
|
||||
enum Event: EventType {
|
||||
/// Start the flow.
|
||||
case start
|
||||
/// The user entered a PIN.
|
||||
case pinEntered
|
||||
/// The user completed the biometrics prompt.
|
||||
case biometricsSet
|
||||
/// The user wants to change their PIN.
|
||||
case changePIN
|
||||
/// The user wants to dismiss the flow.
|
||||
case dismiss
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
private let actionsSubject: PassthroughSubject<AppLockSetupFlowCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<AppLockSetupFlowCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(presentingFlow: PresentationFlow, appLockService: AppLockServiceProtocol, navigationStackCoordinator: NavigationStackCoordinator) {
|
||||
self.presentingFlow = presentingFlow
|
||||
self.appLockService = appLockService
|
||||
self.navigationStackCoordinator = navigationStackCoordinator
|
||||
|
||||
stateMachine = .init(state: .initial)
|
||||
configureStateMachine()
|
||||
}
|
||||
|
||||
func start() {
|
||||
stateMachine.tryEvent(.start)
|
||||
}
|
||||
|
||||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
|
||||
// Deep links not supported.
|
||||
}
|
||||
|
||||
func clearRoute(animated: Bool) {
|
||||
// Deep links not supported.
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func configureStateMachine() {
|
||||
stateMachine.addRouteMapping { [weak self] event, fromState, _ in
|
||||
guard let self else { return nil }
|
||||
|
||||
switch (event, fromState) {
|
||||
case (.start, .initial):
|
||||
if presentingFlow == .authentication { return .createPIN }
|
||||
return appLockService.isEnabled ? .unlock : .createPIN
|
||||
case (.pinEntered, .unlock):
|
||||
return .settings
|
||||
case (.pinEntered, .createPIN):
|
||||
if presentingFlow == .authentication {
|
||||
return appLockService.biometryType != .none ? .biometricsPrompt : .complete
|
||||
} else {
|
||||
return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt
|
||||
}
|
||||
case (.biometricsSet, .biometricsPrompt):
|
||||
return presentingFlow == .settings ? .settings : .complete
|
||||
case (.changePIN, .settings):
|
||||
return .createPIN
|
||||
case (.dismiss, _):
|
||||
return .complete
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
|
||||
guard let self else { return }
|
||||
|
||||
MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(String(describing: context.event))`.")
|
||||
switch (context.fromState, context.toState) {
|
||||
case (.initial, .unlock):
|
||||
showPINUnlock()
|
||||
case (.initial, .createPIN):
|
||||
showCreatePIN()
|
||||
case (.unlock, .settings):
|
||||
showSettings()
|
||||
case (.createPIN, .biometricsPrompt):
|
||||
showBiometricsPrompt()
|
||||
case (.createPIN, .settings):
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case (.biometricsPrompt, .settings):
|
||||
showSettings()
|
||||
case (.settings, .createPIN):
|
||||
showCreatePIN()
|
||||
case (_, .complete):
|
||||
complete(from: context.fromState)
|
||||
default:
|
||||
fatalError("Unhandled transition.")
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Unexpected transition from `\(context.fromState)` to `\(context.toState)` with event `\(String(describing: context.event))`.")
|
||||
}
|
||||
}
|
||||
|
||||
private func showCreatePIN() {
|
||||
// Despite appLockService.isMandatory existing, we don't use that here,
|
||||
// to allow for cancellation when changing the PIN code within settings.
|
||||
let isMandatory = presentingFlow == .authentication
|
||||
|
||||
let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .create,
|
||||
isMandatory: isMandatory,
|
||||
appLockService: appLockService))
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .cancel:
|
||||
stateMachine.tryEvent(.dismiss)
|
||||
case .complete:
|
||||
stateMachine.tryEvent(.pinEntered)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
if presentingFlow == .authentication {
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
} else {
|
||||
modalNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
navigationStackCoordinator.setSheetCoordinator(modalNavigationStackCoordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func showBiometricsPrompt() {
|
||||
let coordinator = AppLockSetupBiometricsScreenCoordinator(parameters: .init(appLockService: appLockService))
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .continue:
|
||||
stateMachine.tryEvent(.biometricsSet)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
if presentingFlow == .authentication {
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
} else {
|
||||
modalNavigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func showPINUnlock() {
|
||||
let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .unlock,
|
||||
isMandatory: false,
|
||||
appLockService: appLockService))
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .cancel:
|
||||
stateMachine.tryEvent(.dismiss)
|
||||
case .complete:
|
||||
stateMachine.tryEvent(.pinEntered)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
modalNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
navigationStackCoordinator.setSheetCoordinator(modalNavigationStackCoordinator)
|
||||
}
|
||||
|
||||
private func showSettings() {
|
||||
let coordinator = AppLockSetupSettingsScreenCoordinator(parameters: .init(isMandatory: appLockService.isMandatory,
|
||||
appLockService: appLockService))
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .changePINCode:
|
||||
stateMachine.tryEvent(.changePIN)
|
||||
case .appLockDisabled:
|
||||
stateMachine.tryEvent(.dismiss)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in
|
||||
self?.actionsSubject.send(.complete)
|
||||
}
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
|
||||
/// Tear down the flow for completion.
|
||||
private func complete(from state: State) {
|
||||
switch state {
|
||||
case .initial, .complete: fatalError()
|
||||
case .unlock:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
actionsSubject.send(.complete)
|
||||
case .createPIN:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
actionsSubject.send(.complete)
|
||||
case .biometricsPrompt:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
actionsSubject.send(.complete)
|
||||
case .settings:
|
||||
navigationStackCoordinator.pop()
|
||||
actionsSubject.send(.complete)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,8 @@ public enum L10n {
|
||||
public static var commonSettings: String { return L10n.tr("Localizable", "common_settings") }
|
||||
/// Shared location
|
||||
public static var commonSharedLocation: String { return L10n.tr("Localizable", "common_shared_location") }
|
||||
/// Signing out
|
||||
public static var commonSigningOut: String { return L10n.tr("Localizable", "common_signing_out") }
|
||||
/// Starting chat…
|
||||
public static var commonStartingChat: String { return L10n.tr("Localizable", "common_starting_chat") }
|
||||
/// Sticker
|
||||
@@ -724,8 +726,10 @@ public enum L10n {
|
||||
public static var screenAppLockSignoutAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_message") }
|
||||
/// You are being signed out
|
||||
public static var screenAppLockSignoutAlertTitle: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_title") }
|
||||
/// You have 3 attempts to unlock
|
||||
public static var screenAppLockSubtitle: String { return L10n.tr("Localizable", "screen_app_lock_subtitle") }
|
||||
/// Plural format key: "%#@COUNT@"
|
||||
public static func screenAppLockSubtitle(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_app_lock_subtitle", p1)
|
||||
}
|
||||
/// Plural format key: "%#@COUNT@"
|
||||
public static func screenAppLockSubtitleWrongPin(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_app_lock_subtitle_wrong_pin", p1)
|
||||
|
||||
@@ -117,6 +117,11 @@ class AnalyticsClientMock: AnalyticsClientProtocol {
|
||||
}
|
||||
}
|
||||
class AppLockServiceMock: AppLockServiceProtocol {
|
||||
var isMandatory: Bool {
|
||||
get { return underlyingIsMandatory }
|
||||
set(value) { underlyingIsMandatory = value }
|
||||
}
|
||||
var underlyingIsMandatory: Bool!
|
||||
var isEnabled: Bool {
|
||||
get { return underlyingIsEnabled }
|
||||
set(value) { underlyingIsEnabled = value }
|
||||
@@ -132,6 +137,21 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
set(value) { underlyingBiometricUnlockEnabled = value }
|
||||
}
|
||||
var underlyingBiometricUnlockEnabled: Bool!
|
||||
var disabledPublisher: AnyPublisher<Void, Never> {
|
||||
get { return underlyingDisabledPublisher }
|
||||
set(value) { underlyingDisabledPublisher = value }
|
||||
}
|
||||
var underlyingDisabledPublisher: AnyPublisher<Void, Never>!
|
||||
var numberOfPINAttempts: AnyPublisher<Int, Never> {
|
||||
get { return underlyingNumberOfPINAttempts }
|
||||
set(value) { underlyingNumberOfPINAttempts = value }
|
||||
}
|
||||
var underlyingNumberOfPINAttempts: AnyPublisher<Int, Never>!
|
||||
var numberOfBiometricAttempts: AnyPublisher<Int, Never> {
|
||||
get { return underlyingNumberOfBiometricAttempts }
|
||||
set(value) { underlyingNumberOfBiometricAttempts = value }
|
||||
}
|
||||
var underlyingNumberOfBiometricAttempts: AnyPublisher<Int, Never>!
|
||||
|
||||
//MARK: - setupPINCode
|
||||
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+5
-2
@@ -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)
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+29
-3
@@ -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
-14
@@ -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))
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -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
|
||||
}
|
||||
+9
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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 }
|
||||
}
|
||||
+16
-13
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,12 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
private let appSettings: AppSettings
|
||||
private let analytics: AnalyticsService
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appLockService: AppLockServiceProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var oidcPresenter: OIDCAuthenticationPresenter?
|
||||
private var appLockFlowCoordinator: AppLockSetupFlowCoordinator?
|
||||
|
||||
weak var delegate: AuthenticationCoordinatorDelegate?
|
||||
|
||||
@@ -40,12 +42,14 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
navigationStackCoordinator: NavigationStackCoordinator,
|
||||
appSettings: AppSettings,
|
||||
analytics: AnalyticsService,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appLockService: AppLockServiceProtocol) {
|
||||
self.authenticationService = authenticationService
|
||||
self.navigationStackCoordinator = navigationStackCoordinator
|
||||
self.appSettings = appSettings
|
||||
self.analytics = analytics
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appLockService = appLockService
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -64,7 +68,7 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
|
||||
oidcPresenter.handleUniversalLinkCallback(url)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func showOnboarding() {
|
||||
@@ -237,25 +241,46 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
private func userHasSignedIn(userSession: UserSessionProtocol) {
|
||||
appSettings.lastLoginDate = .now
|
||||
|
||||
showAnalyticsPromptIfNeeded { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
if appSettings.appLockIsMandatory, !appLockService.isEnabled {
|
||||
showAppLockSetupFlow(userSession: userSession)
|
||||
} else if analytics.shouldShowAnalyticsPrompt {
|
||||
showAnalyticsPrompt(userSession: userSession)
|
||||
} else {
|
||||
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
}
|
||||
}
|
||||
|
||||
private func showAnalyticsPromptIfNeeded(completion: @escaping () -> Void) {
|
||||
guard analytics.shouldShowAnalyticsPrompt else {
|
||||
completion()
|
||||
return
|
||||
|
||||
private func showAppLockSetupFlow(userSession: UserSessionProtocol) {
|
||||
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .authentication,
|
||||
appLockService: appLockService,
|
||||
navigationStackCoordinator: navigationStackCoordinator)
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .complete:
|
||||
appLockFlowCoordinator = nil
|
||||
if analytics.shouldShowAnalyticsPrompt {
|
||||
showAnalyticsPrompt(userSession: userSession)
|
||||
} else {
|
||||
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
appLockFlowCoordinator = coordinator
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
private func showAnalyticsPrompt(userSession: UserSessionProtocol) {
|
||||
let coordinator = AnalyticsPromptScreenCoordinator(analytics: analytics, termsURL: appSettings.analyticsConfiguration.termsURL)
|
||||
|
||||
coordinator.actions
|
||||
.sink { action in
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .done:
|
||||
completion()
|
||||
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -39,6 +39,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: SettingsScreenCoordinatorParameters
|
||||
private var viewModel: SettingsScreenViewModelProtocol
|
||||
|
||||
private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator?
|
||||
|
||||
private let actionsSubject: PassthroughSubject<SettingsScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -68,7 +70,7 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
case .analytics:
|
||||
presentAnalyticsScreen()
|
||||
case .appLock:
|
||||
presentAppLockSettingsScreen()
|
||||
presentAppLockSetupFlow()
|
||||
case .reportBug:
|
||||
presentBugReportScreen()
|
||||
case .about:
|
||||
@@ -146,9 +148,27 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
parameters.navigationStackCoordinator?.push(coordinator)
|
||||
}
|
||||
|
||||
private func presentAppLockSettingsScreen() {
|
||||
let coordinator = AppLockSettingsScreenCoordinator(parameters: .init(appLockService: parameters.appLockService))
|
||||
parameters.navigationStackCoordinator?.push(coordinator)
|
||||
private func presentAppLockSetupFlow() {
|
||||
guard let navigationStackCoordinator = parameters.navigationStackCoordinator else {
|
||||
MXLog.error("The navigation stack has gone! 🌫️")
|
||||
return
|
||||
}
|
||||
|
||||
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .settings,
|
||||
appLockService: parameters.appLockService,
|
||||
navigationStackCoordinator: navigationStackCoordinator)
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .complete:
|
||||
// The flow coordinator tidies up the stack, no need to do anything.
|
||||
appLockSetupFlowCoordinator = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
appLockSetupFlowCoordinator = coordinator
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
private func presentBugReportScreen() {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import LocalAuthentication
|
||||
|
||||
/// The service responsible for locking and unlocking the app.
|
||||
@@ -24,6 +25,8 @@ class AppLockService: AppLockServiceProtocol {
|
||||
|
||||
private let timer: AppLockTimer
|
||||
|
||||
var isMandatory: Bool { appSettings.appLockIsMandatory }
|
||||
|
||||
var isEnabled: Bool {
|
||||
do {
|
||||
guard appSettings.appLockFlowEnabled else { return false }
|
||||
@@ -38,6 +41,12 @@ class AppLockService: AppLockServiceProtocol {
|
||||
var biometryType: LABiometryType { context.biometryType }
|
||||
var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet.
|
||||
|
||||
var numberOfPINAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfPINAttempts }
|
||||
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfBiometricAttempts }
|
||||
|
||||
private var disabledSubject: PassthroughSubject<Void, Never> = .init()
|
||||
var disabledPublisher: AnyPublisher<Void, Never> { disabledSubject.eraseToAnyPublisher() }
|
||||
|
||||
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) {
|
||||
self.keychainController = keychainController
|
||||
self.appSettings = appSettings
|
||||
@@ -68,6 +77,9 @@ class AppLockService: AppLockServiceProtocol {
|
||||
func disable() {
|
||||
biometricUnlockEnabled = false
|
||||
keychainController.removePINCode()
|
||||
appSettings.appLockNumberOfPINAttempts = 0
|
||||
appSettings.appLockNumberOfBiometricAttempts = 0
|
||||
disabledSubject.send()
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground() {
|
||||
@@ -79,12 +91,20 @@ class AppLockService: AppLockServiceProtocol {
|
||||
}
|
||||
|
||||
func unlock(with pinCode: String) -> Bool {
|
||||
guard pinCode == keychainController.pinCode() else { return false }
|
||||
guard pinCode == keychainController.pinCode() else {
|
||||
MXLog.warning("Wrong PIN entered.")
|
||||
appSettings.appLockNumberOfPINAttempts += 1
|
||||
return false
|
||||
}
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
guard biometryType != .none, biometricUnlockEnabled else { return false }
|
||||
guard biometryType != .none, biometricUnlockEnabled else {
|
||||
MXLog.warning("\(biometryType) failed.")
|
||||
appSettings.appLockNumberOfBiometricAttempts += 1
|
||||
return false
|
||||
}
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
@@ -103,6 +123,8 @@ class AppLockService: AppLockServiceProtocol {
|
||||
/// Shared logic for completing an unlock via a PIN or biometry.
|
||||
private func completeUnlock() -> Bool {
|
||||
timer.registerUnlock()
|
||||
appSettings.appLockNumberOfPINAttempts = 0
|
||||
appSettings.appLockNumberOfBiometricAttempts = 0
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import LocalAuthentication
|
||||
|
||||
enum AppLockServiceError: Error {
|
||||
@@ -27,13 +28,19 @@ enum AppLockServiceError: Error {
|
||||
|
||||
@MainActor
|
||||
protocol AppLockServiceProtocol: AnyObject {
|
||||
/// The use of a PIN code is mandatory for this device.
|
||||
var isMandatory: Bool { get }
|
||||
/// The app has been configured to automatically lock with a PIN code.
|
||||
var isEnabled: Bool { get }
|
||||
|
||||
/// The type of biometric authentication supported by the device.
|
||||
var biometryType: LABiometryType { get }
|
||||
/// Whether or not the user has enabled unlock via TouchID, FaceID or (possibly) OpticID.
|
||||
var biometricUnlockEnabled: Bool { get set }
|
||||
|
||||
/// A publisher that advertises when the service has been disabled.
|
||||
var disabledPublisher: AnyPublisher<Void, Never> { get }
|
||||
|
||||
/// 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.
|
||||
@@ -50,15 +57,23 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
func unlock(with pinCode: String) -> Bool
|
||||
/// Attempt to unlock the app using FaceID or TouchID.
|
||||
func unlockWithBiometrics() -> Bool
|
||||
|
||||
/// The number of attempts the user had made to unlock with a PIN code.
|
||||
var numberOfPINAttempts: AnyPublisher<Int, Never> { get }
|
||||
/// The number of attempts the user has made to unlock with Touch/Face ID.
|
||||
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { get }
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
extension AppLockServiceProtocol { }
|
||||
|
||||
extension AppLockServiceMock {
|
||||
static func mock(pinCode: String? = "2023", biometryType: LABiometryType = .faceID) -> AppLockServiceMock {
|
||||
static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID) -> AppLockServiceMock {
|
||||
let mock = AppLockServiceMock()
|
||||
mock.isEnabled = pinCode != nil
|
||||
mock.isMandatory = isMandatory
|
||||
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
|
||||
mock.numberOfBiometricAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
|
||||
mock.underlyingBiometryType = biometryType
|
||||
mock.underlyingBiometricUnlockEnabled = biometryType != .none
|
||||
mock.unlockWithClosure = { $0 == pinCode }
|
||||
|
||||
@@ -127,7 +127,8 @@ class MockScreen: Identifiable {
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appLockService: AppLockServiceMock())
|
||||
retainedState.append(coordinator)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@@ -162,17 +163,12 @@ class MockScreen: Identifiable {
|
||||
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)
|
||||
let coordinator = AppLockSettingsScreenCoordinator(parameters: .init(appLockService: appLockService))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .settings,
|
||||
appLockService: appLockService,
|
||||
navigationStackCoordinator: navigationStackCoordinator)
|
||||
coordinator.start()
|
||||
return navigationStackCoordinator
|
||||
case .home:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
@@ -31,7 +31,6 @@ enum UITestsScreenIdentifier: String {
|
||||
case templateScreen
|
||||
case appLockScreen
|
||||
case appLockSetupFlow
|
||||
case appLockSettingsScreen
|
||||
case home
|
||||
case settings
|
||||
case bugReport
|
||||
|
||||
Reference in New Issue
Block a user