Add support for Face ID/Touch ID to app lock. (#1966)
* Fix biometrics with low grace period and backgrounding before unlocked. * Trigger permissions alert when enabling Face ID.
This commit is contained in:
@@ -70,6 +70,7 @@
|
||||
126EE01D8BEAEF26105D83C5 /* RoomDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */; };
|
||||
12C867E85E6D12EEDFD0B127 /* CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C4762F8D6112E43117DB2F /* CustomStringConvertible.swift */; };
|
||||
12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3F82523D6F48B926D6AF68 /* AppSettings.swift */; };
|
||||
1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */; };
|
||||
13853973A5E24374FCEDE8A3 /* RedactedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */; };
|
||||
13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; };
|
||||
13CBC470FB619A6393A21908 /* RoomNotificationSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.swift */; };
|
||||
@@ -596,13 +597,13 @@
|
||||
9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; };
|
||||
9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; };
|
||||
9F19096BFA629C0AC282B1E4 /* CreateRoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */; };
|
||||
9F30A18B50D13B10D8444984 /* ApplicationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62011D547772F3DF5D924823 /* ApplicationMock.swift */; };
|
||||
9FAF6DA7E8E85C9699757764 /* CollapsibleRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2656184491C505700D2405 /* CollapsibleRoomTimelineView.swift */; };
|
||||
9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */; };
|
||||
A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */; };
|
||||
A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; };
|
||||
A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */; };
|
||||
A0D7E5BD0298A97DCBDCE40B /* Prefire in Frameworks */ = {isa = PBXBuildFile; productRef = 2629CF48B33643CD5F69C612 /* Prefire */; };
|
||||
A0FAB2BA2AE92AB5008F20B3 /* VoiceMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */; };
|
||||
A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */; };
|
||||
A14A9419105A1CD42F0511C4 /* UserIndicatorModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */; };
|
||||
A17FAD2EBC53E17B5FD384DB /* InviteUsersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.swift */; };
|
||||
@@ -635,7 +636,6 @@
|
||||
A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; };
|
||||
A743841F91B62B0E56217B04 /* SecureBackupKeyBackupScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DCB219D7B7B0299358FF81 /* SecureBackupKeyBackupScreenUITests.swift */; };
|
||||
A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; };
|
||||
A7BACE682AE97D4500FFBBEA /* ApplicationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7BACE672AE97D4500FFBBEA /* ApplicationMock.swift */; };
|
||||
A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; };
|
||||
A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; };
|
||||
A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; };
|
||||
@@ -1032,7 +1032,7 @@
|
||||
033DB41C51865A2E83174E87 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
|
||||
035177BCD8E8308B098AC3C2 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = "<group>"; };
|
||||
0376C429FAB1687C3D905F3E /* MockCoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCoder.swift; sourceTree = "<group>"; };
|
||||
0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = test_voice_message.m4a; sourceTree = "<group>"; };
|
||||
0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */ = {isa = PBXFileReference; path = test_voice_message.m4a; sourceTree = "<group>"; };
|
||||
03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformViewDragGestureModifier.swift; sourceTree = "<group>"; };
|
||||
03DD998E523D4EC93C7ED703 /* RoomNotificationSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
@@ -1364,6 +1364,7 @@
|
||||
6033779EB37259F27F938937 /* ClientProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyProtocol.swift; sourceTree = "<group>"; };
|
||||
60F18AECC9D38C2B6D85F99C /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = "<group>"; };
|
||||
612EF972F2A1800682D32C5E /* StickerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||
62011D547772F3DF5D924823 /* ApplicationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationMock.swift; sourceTree = "<group>"; };
|
||||
622D09D4ECE759189009AEAF /* MapLibreMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreMapView.swift; sourceTree = "<group>"; };
|
||||
62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderTests.swift; sourceTree = "<group>"; };
|
||||
638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveQuickLook.swift; sourceTree = "<group>"; };
|
||||
@@ -1567,7 +1568,6 @@
|
||||
A05707BF550D770168A406DB /* LoginViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModelTests.swift; sourceTree = "<group>"; };
|
||||
A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = "<group>"; };
|
||||
A0A01AECFF54281CF35909A6 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = "<group>"; };
|
||||
A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = "<group>"; };
|
||||
A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = "<group>"; };
|
||||
A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = "<group>"; };
|
||||
@@ -1588,7 +1588,6 @@
|
||||
A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = "<group>"; };
|
||||
A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriverProtocol.swift; sourceTree = "<group>"; };
|
||||
A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; };
|
||||
A7BACE672AE97D4500FFBBEA /* ApplicationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationMock.swift; sourceTree = "<group>"; };
|
||||
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
|
||||
A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -1658,6 +1657,7 @@
|
||||
B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
B81B6170DB690013CEB646F4 /* MapLibreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreModels.swift; sourceTree = "<group>"; };
|
||||
B83BC0DC9A2DF2DD60F9B6E9 /* NotificationSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenUITests.swift; sourceTree = "<group>"; };
|
||||
B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = "<group>"; };
|
||||
B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregratedReaction.swift; sourceTree = "<group>"; };
|
||||
B8A3B7637DDBD6AA97AC2545 /* CameraPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPicker.swift; sourceTree = "<group>"; };
|
||||
B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = "<group>"; };
|
||||
@@ -2433,6 +2433,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */,
|
||||
62011D547772F3DF5D924823 /* ApplicationMock.swift */,
|
||||
382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */,
|
||||
D38391154120264910D19528 /* PollMock.swift */,
|
||||
36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */,
|
||||
@@ -2443,7 +2444,6 @@
|
||||
AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */,
|
||||
B23135B06B044CB811139D2F /* Generated */,
|
||||
E5E545F92D01588360A9BAC5 /* SDK */,
|
||||
A7BACE672AE97D4500FFBBEA /* ApplicationMock.swift */,
|
||||
);
|
||||
path = Mocks;
|
||||
sourceTree = "<group>";
|
||||
@@ -3859,7 +3859,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */,
|
||||
A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */,
|
||||
B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */,
|
||||
FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */,
|
||||
94028A227645FA880B966211 /* WaveformSource.swift */,
|
||||
03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */,
|
||||
@@ -5295,7 +5295,6 @@
|
||||
0F6C8033FA60CFD36F7CA205 /* AppLockSetupPINScreenViewModel.swift in Sources */,
|
||||
4C356F5CCB4CDC99BFA45185 /* AppLockSetupPINScreenViewModelProtocol.swift in Sources */,
|
||||
4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */,
|
||||
A7BACE682AE97D4500FFBBEA /* ApplicationMock.swift in Sources */,
|
||||
8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */,
|
||||
6E4E401BE97AC241DA7C7716 /* AppLockSetupSettingsScreenModels.swift in Sources */,
|
||||
4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */,
|
||||
@@ -5304,6 +5303,7 @@
|
||||
355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */,
|
||||
12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */,
|
||||
9462C62798F47E39DCC182D2 /* Application.swift in Sources */,
|
||||
9F30A18B50D13B10D8444984 /* ApplicationMock.swift in Sources */,
|
||||
74604ACFDBE7F54260E7B617 /* ApplicationProtocol.swift in Sources */,
|
||||
61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */,
|
||||
90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */,
|
||||
@@ -5577,7 +5577,6 @@
|
||||
C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */,
|
||||
AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */,
|
||||
53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */,
|
||||
A0FAB2BA2AE92AB5008F20B3 /* VoiceMessageButton.swift in Sources */,
|
||||
4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */,
|
||||
119AE9A3FC6E0606C1146528 /* NotificationSettingsEditScreenRoomCell.swift in Sources */,
|
||||
D5FE90A6AF5FD5AE91BD37C7 /* NotificationSettingsEditScreenViewModel.swift in Sources */,
|
||||
@@ -5877,6 +5876,7 @@
|
||||
1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */,
|
||||
64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */,
|
||||
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */,
|
||||
1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */,
|
||||
4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */,
|
||||
4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */,
|
||||
386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"action_edit" = "Edit";
|
||||
"action_enable" = "Enable";
|
||||
"action_end_poll" = "End poll";
|
||||
"action_enter_pin" = "Enter PIN";
|
||||
"action_forgot_password" = "Forgot password?";
|
||||
"action_forward" = "Forward";
|
||||
"action_invite" = "Invite";
|
||||
@@ -277,6 +278,7 @@
|
||||
"screen_analytics_settings_share_data" = "Share analytics data";
|
||||
"screen_app_lock_biometric_authentication" = "biometric authentication";
|
||||
"screen_app_lock_biometric_unlock" = "biometric unlock";
|
||||
"screen_app_lock_biometric_unlock_reason_ios" = "Authentication is needed to access your app";
|
||||
"screen_app_lock_forgot_pin" = "Forgot PIN?";
|
||||
"screen_app_lock_settings_change_pin" = "Change PIN code";
|
||||
"screen_app_lock_settings_enable_biometric_unlock" = "Allow biometric unlock";
|
||||
|
||||
@@ -291,7 +291,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
if oldVersion < Version(1, 1, 0) {
|
||||
MXLog.info("Migrating to v1.1.0, signing out the user.")
|
||||
// Version 1.1.0 switched the Rust crypto store to SQLite
|
||||
// There are no migrations in place so we need to reset everything
|
||||
// There are no migrations in place so we need to sign the user out
|
||||
wipeUserData()
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
private func wipeUserData(includingSettings: Bool = false) {
|
||||
if includingSettings {
|
||||
AppSettings.reset()
|
||||
appLockFlowCoordinator.appLockService.disable()
|
||||
}
|
||||
userSessionStore.reset()
|
||||
}
|
||||
|
||||
@@ -133,9 +133,6 @@ final class AppSettings {
|
||||
/// 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
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
let userIndicatorController: UserIndicatorController
|
||||
let navigationCoordinator: NavigationRootCoordinator
|
||||
|
||||
/// A task used to await biometric unlock before showing the PIN screen.
|
||||
@CancellableTask private var unlockTask: Task<Void, Never>?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
private let actionsSubject: PassthroughSubject<AppLockFlowCoordinatorAction, Never> = .init()
|
||||
@@ -44,6 +46,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.navigationCoordinator = navigationCoordinator
|
||||
|
||||
// Set the initial background state.
|
||||
showPlaceholder()
|
||||
|
||||
appLockService.disabledPublisher
|
||||
.sink { [weak self] _ in
|
||||
// When the service is disabled via a force logout, we need to remove the activity indicator.
|
||||
@@ -71,6 +76,8 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
// MARK: - App unlock
|
||||
|
||||
private func applicationDidEnterBackground() {
|
||||
unlockTask = nil
|
||||
|
||||
guard appLockService.isEnabled else { return }
|
||||
|
||||
appLockService.applicationDidEnterBackground()
|
||||
@@ -80,12 +87,33 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
private func applicationWillEnterForeground() {
|
||||
guard appLockService.isEnabled else { return }
|
||||
|
||||
if appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) {
|
||||
showUnlockScreen()
|
||||
} else {
|
||||
guard appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else {
|
||||
// Reveal the app again if within the grace period.
|
||||
actionsSubject.send(.unlockApp)
|
||||
return
|
||||
}
|
||||
|
||||
// Show the relevant unlock mechanism.
|
||||
unlockTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await startUnlockFlow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the unlock flow, showing Touch ID/Face ID if available, transitioning to PIN unlock if it fails or isn't available.
|
||||
private func startUnlockFlow() async {
|
||||
if appLockService.biometricUnlockEnabled, appLockService.biometricUnlockTrusted {
|
||||
showPlaceholder() // For the unlock background.
|
||||
|
||||
if await appLockService.unlockWithBiometrics() {
|
||||
actionsSubject.send(.unlockApp)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
showUnlockScreen()
|
||||
}
|
||||
|
||||
/// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher.
|
||||
|
||||
@@ -140,6 +140,9 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
||||
showBiometricsPrompt()
|
||||
case (.createPIN, .settings):
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
// The above is fine for change pin, but not create PIN when biometrics are unavailable.
|
||||
// Need to track the two flows differently to call the alternative below.
|
||||
// showSettings()
|
||||
case (.biometricsPrompt, .settings):
|
||||
showSettings()
|
||||
case (.settings, .createPIN):
|
||||
|
||||
@@ -82,6 +82,8 @@ public enum L10n {
|
||||
public static var actionEnable: String { return L10n.tr("Localizable", "action_enable") }
|
||||
/// End poll
|
||||
public static var actionEndPoll: String { return L10n.tr("Localizable", "action_end_poll") }
|
||||
/// Enter PIN
|
||||
public static var actionEnterPin: String { return L10n.tr("Localizable", "action_enter_pin") }
|
||||
/// Forgot password?
|
||||
public static var actionForgotPassword: String { return L10n.tr("Localizable", "action_forgot_password") }
|
||||
/// Forward
|
||||
@@ -676,6 +678,8 @@ public enum L10n {
|
||||
public static var screenAppLockBiometricAuthentication: String { return L10n.tr("Localizable", "screen_app_lock_biometric_authentication") }
|
||||
/// biometric unlock
|
||||
public static var screenAppLockBiometricUnlock: String { return L10n.tr("Localizable", "screen_app_lock_biometric_unlock") }
|
||||
/// Authentication is needed to access your app
|
||||
public static var screenAppLockBiometricUnlockReasonIos: String { return L10n.tr("Localizable", "screen_app_lock_biometric_unlock_reason_ios") }
|
||||
/// Forgot PIN?
|
||||
public static var screenAppLockForgotPin: String { return L10n.tr("Localizable", "screen_app_lock_forgot_pin") }
|
||||
/// Change PIN code
|
||||
|
||||
@@ -137,6 +137,11 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
set(value) { underlyingBiometricUnlockEnabled = value }
|
||||
}
|
||||
var underlyingBiometricUnlockEnabled: Bool!
|
||||
var biometricUnlockTrusted: Bool {
|
||||
get { return underlyingBiometricUnlockTrusted }
|
||||
set(value) { underlyingBiometricUnlockTrusted = value }
|
||||
}
|
||||
var underlyingBiometricUnlockTrusted: Bool!
|
||||
var disabledPublisher: AnyPublisher<Void, Never> {
|
||||
get { return underlyingDisabledPublisher }
|
||||
set(value) { underlyingDisabledPublisher = value }
|
||||
@@ -147,11 +152,6 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
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
|
||||
|
||||
@@ -195,6 +195,35 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
return validateReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - enableBiometricUnlock
|
||||
|
||||
var enableBiometricUnlockCallsCount = 0
|
||||
var enableBiometricUnlockCalled: Bool {
|
||||
return enableBiometricUnlockCallsCount > 0
|
||||
}
|
||||
var enableBiometricUnlockReturnValue: Result<Void, AppLockServiceError>!
|
||||
var enableBiometricUnlockClosure: (() -> Result<Void, AppLockServiceError>)?
|
||||
|
||||
func enableBiometricUnlock() -> Result<Void, AppLockServiceError> {
|
||||
enableBiometricUnlockCallsCount += 1
|
||||
if let enableBiometricUnlockClosure = enableBiometricUnlockClosure {
|
||||
return enableBiometricUnlockClosure()
|
||||
} else {
|
||||
return enableBiometricUnlockReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - disableBiometricUnlock
|
||||
|
||||
var disableBiometricUnlockCallsCount = 0
|
||||
var disableBiometricUnlockCalled: Bool {
|
||||
return disableBiometricUnlockCallsCount > 0
|
||||
}
|
||||
var disableBiometricUnlockClosure: (() -> Void)?
|
||||
|
||||
func disableBiometricUnlock() {
|
||||
disableBiometricUnlockCallsCount += 1
|
||||
disableBiometricUnlockClosure?()
|
||||
}
|
||||
//MARK: - disable
|
||||
|
||||
var disableCallsCount = 0
|
||||
@@ -268,12 +297,12 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
return unlockWithBiometricsCallsCount > 0
|
||||
}
|
||||
var unlockWithBiometricsReturnValue: Bool!
|
||||
var unlockWithBiometricsClosure: (() -> Bool)?
|
||||
var unlockWithBiometricsClosure: (() async -> Bool)?
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
unlockWithBiometricsCallsCount += 1
|
||||
if let unlockWithBiometricsClosure = unlockWithBiometricsClosure {
|
||||
return unlockWithBiometricsClosure()
|
||||
return await unlockWithBiometricsClosure()
|
||||
} else {
|
||||
return unlockWithBiometricsReturnValue
|
||||
}
|
||||
@@ -906,6 +935,72 @@ class KeychainControllerMock: KeychainControllerProtocol {
|
||||
removePINCodeCallsCount += 1
|
||||
removePINCodeClosure?()
|
||||
}
|
||||
//MARK: - containsPINCodeBiometricState
|
||||
|
||||
var containsPINCodeBiometricStateCallsCount = 0
|
||||
var containsPINCodeBiometricStateCalled: Bool {
|
||||
return containsPINCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var containsPINCodeBiometricStateReturnValue: Bool!
|
||||
var containsPINCodeBiometricStateClosure: (() -> Bool)?
|
||||
|
||||
func containsPINCodeBiometricState() -> Bool {
|
||||
containsPINCodeBiometricStateCallsCount += 1
|
||||
if let containsPINCodeBiometricStateClosure = containsPINCodeBiometricStateClosure {
|
||||
return containsPINCodeBiometricStateClosure()
|
||||
} else {
|
||||
return containsPINCodeBiometricStateReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - setPINCodeBiometricState
|
||||
|
||||
var setPINCodeBiometricStateThrowableError: Error?
|
||||
var setPINCodeBiometricStateCallsCount = 0
|
||||
var setPINCodeBiometricStateCalled: Bool {
|
||||
return setPINCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var setPINCodeBiometricStateReceivedState: Data?
|
||||
var setPINCodeBiometricStateReceivedInvocations: [Data] = []
|
||||
var setPINCodeBiometricStateClosure: ((Data) throws -> Void)?
|
||||
|
||||
func setPINCodeBiometricState(_ state: Data) throws {
|
||||
if let error = setPINCodeBiometricStateThrowableError {
|
||||
throw error
|
||||
}
|
||||
setPINCodeBiometricStateCallsCount += 1
|
||||
setPINCodeBiometricStateReceivedState = state
|
||||
setPINCodeBiometricStateReceivedInvocations.append(state)
|
||||
try setPINCodeBiometricStateClosure?(state)
|
||||
}
|
||||
//MARK: - pinCodeBiometricState
|
||||
|
||||
var pinCodeBiometricStateCallsCount = 0
|
||||
var pinCodeBiometricStateCalled: Bool {
|
||||
return pinCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var pinCodeBiometricStateReturnValue: Data?
|
||||
var pinCodeBiometricStateClosure: (() -> Data?)?
|
||||
|
||||
func pinCodeBiometricState() -> Data? {
|
||||
pinCodeBiometricStateCallsCount += 1
|
||||
if let pinCodeBiometricStateClosure = pinCodeBiometricStateClosure {
|
||||
return pinCodeBiometricStateClosure()
|
||||
} else {
|
||||
return pinCodeBiometricStateReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - removePINCodeBiometricState
|
||||
|
||||
var removePINCodeBiometricStateCallsCount = 0
|
||||
var removePINCodeBiometricStateCalled: Bool {
|
||||
return removePINCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var removePINCodeBiometricStateClosure: (() -> Void)?
|
||||
|
||||
func removePINCodeBiometricState() {
|
||||
removePINCodeBiometricStateCallsCount += 1
|
||||
removePINCodeBiometricStateClosure?()
|
||||
}
|
||||
}
|
||||
class MediaPlayerMock: MediaPlayerProtocol {
|
||||
var mediaSource: MediaSourceProxy?
|
||||
|
||||
@@ -46,11 +46,7 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
||||
|
||||
switch viewAction {
|
||||
case .submitPINCode:
|
||||
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
||||
handleInvalidPIN()
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.appUnlocked)
|
||||
submitPINCode()
|
||||
case .clearPINCode:
|
||||
state.bindings.pinCode = ""
|
||||
case .forgotPIN:
|
||||
@@ -60,6 +56,14 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func submitPINCode() {
|
||||
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
||||
handleInvalidPIN()
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.appUnlocked)
|
||||
}
|
||||
|
||||
private func handleForgotPIN() {
|
||||
state.bindings.alertInfo = .init(id: .confirmResetPIN,
|
||||
title: L10n.screenAppLockSignoutAlertTitle,
|
||||
|
||||
@@ -39,13 +39,34 @@ class AppLockSetupBiometricsScreenViewModel: AppLockSetupBiometricsScreenViewMod
|
||||
|
||||
switch viewAction {
|
||||
case .allow:
|
||||
MXLog.info("Enable biometric unlock.")
|
||||
appLockService.biometricUnlockEnabled = true
|
||||
actionsSubject.send(.continue)
|
||||
Task { await enableBiometricUnlock() }
|
||||
case .skip:
|
||||
MXLog.info("Disable biometric unlock.")
|
||||
appLockService.biometricUnlockEnabled = false
|
||||
actionsSubject.send(.continue)
|
||||
disableBiometricUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func enableBiometricUnlock() async {
|
||||
guard case .success = appLockService.enableBiometricUnlock() else {
|
||||
MXLog.error("Enabling biometric unlock failed.")
|
||||
return
|
||||
}
|
||||
MXLog.info("Biometric unlock enabled.")
|
||||
|
||||
// Attempt unlock to trigger Face ID permissions alert.
|
||||
if appLockService.biometryType == .faceID,
|
||||
await !appLockService.unlockWithBiometrics() {
|
||||
MXLog.info("Confirmation failed. Disabling biometric unlock.")
|
||||
appLockService.disableBiometricUnlock()
|
||||
}
|
||||
|
||||
actionsSubject.send(.continue)
|
||||
}
|
||||
|
||||
private func disableBiometricUnlock() {
|
||||
appLockService.disableBiometricUnlock()
|
||||
MXLog.info("Biometric unlock disabled.")
|
||||
actionsSubject.send(.continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ struct AppLockSetupSettingsScreenViewState: BindableState {
|
||||
let biometryType: LABiometryType
|
||||
var bindings: AppLockSetupSettingsScreenViewStateBindings
|
||||
|
||||
var supportsBiometry: Bool { biometryType != .none }
|
||||
var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
|
||||
var supportsBiometrics: Bool { biometryType != .none }
|
||||
var enableBiometricsTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
|
||||
}
|
||||
|
||||
struct AppLockSetupSettingsScreenViewStateBindings {
|
||||
|
||||
@@ -45,12 +45,34 @@ class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelTy
|
||||
case .disable:
|
||||
showRemovePINAlert()
|
||||
case .enableBiometricsChanged:
|
||||
appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics
|
||||
Task { await toggleBiometrics() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func toggleBiometrics() async {
|
||||
if state.bindings.enableBiometrics {
|
||||
guard case .success = appLockService.enableBiometricUnlock() else {
|
||||
MXLog.error("Enabling biometric unlock failed.")
|
||||
state.bindings.enableBiometrics = false
|
||||
return
|
||||
}
|
||||
MXLog.info("Biometric unlock enabled.")
|
||||
|
||||
// Attempt unlock to trigger Face ID permissions alert.
|
||||
if appLockService.biometryType == .faceID,
|
||||
await !appLockService.unlockWithBiometrics() {
|
||||
MXLog.info("Confirmation failed. Disabling biometric unlock.")
|
||||
state.bindings.enableBiometrics = false
|
||||
appLockService.disableBiometricUnlock()
|
||||
}
|
||||
} else {
|
||||
appLockService.disableBiometricUnlock()
|
||||
MXLog.info("Biometric unlock disabled.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a confirmation alert to the user before removing their PIN code.
|
||||
private func showRemovePINAlert() {
|
||||
state.bindings.alertInfo = .init(id: .confirmRemovePINCode,
|
||||
|
||||
@@ -32,9 +32,9 @@ struct AppLockSetupSettingsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.supportsBiometry {
|
||||
if context.viewState.supportsBiometrics {
|
||||
Section {
|
||||
ListRow(label: .plain(title: context.viewState.enableBiometryTitle),
|
||||
ListRow(label: .plain(title: context.viewState.enableBiometricsTitle),
|
||||
kind: .toggle($context.enableBiometrics))
|
||||
.onChange(of: context.enableBiometrics) { _ in
|
||||
context.send(viewAction: .enableBiometricsChanged)
|
||||
|
||||
@@ -22,7 +22,7 @@ extension LABiometryType {
|
||||
var systemSymbol: SFSymbol {
|
||||
switch self {
|
||||
case .none:
|
||||
MXLog.error("Invalid presentation: Biometry not supported.")
|
||||
MXLog.error("Invalid presentation: Biometrics not supported.")
|
||||
return .viewfinder
|
||||
case .touchID:
|
||||
return .touchid
|
||||
@@ -40,7 +40,7 @@ extension LABiometryType {
|
||||
var localizedString: String {
|
||||
switch self {
|
||||
case .none:
|
||||
MXLog.error("Invalid presentation: Biometry not supported.")
|
||||
MXLog.error("Invalid presentation: Biometrics not supported.")
|
||||
return L10n.screenAppLockBiometricUnlock
|
||||
case .touchID:
|
||||
return L10n.commonTouchIdIos
|
||||
|
||||
@@ -21,9 +21,10 @@ import LocalAuthentication
|
||||
class AppLockService: AppLockServiceProtocol {
|
||||
private let keychainController: KeychainControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let context = LAContext()
|
||||
private let context: LAContext
|
||||
|
||||
private let timer: AppLockTimer
|
||||
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
||||
|
||||
var isMandatory: Bool { appSettings.appLockIsMandatory }
|
||||
|
||||
@@ -38,21 +39,34 @@ 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 biometryType: LABiometryType {
|
||||
updateBiometrics()
|
||||
guard context.evaluatedPolicyDomainState != nil else { return .none }
|
||||
return context.biometryType
|
||||
}
|
||||
|
||||
var biometricUnlockEnabled: Bool {
|
||||
keychainController.containsPINCodeBiometricState()
|
||||
}
|
||||
|
||||
var biometricUnlockTrusted: Bool {
|
||||
guard let state = keychainController.pinCodeBiometricState() else { return false }
|
||||
updateBiometrics()
|
||||
return state == context.evaluatedPolicyDomainState
|
||||
}
|
||||
|
||||
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) {
|
||||
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) {
|
||||
self.keychainController = keychainController
|
||||
self.appSettings = appSettings
|
||||
self.context = context
|
||||
timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod)
|
||||
|
||||
configureBiometrics()
|
||||
updateBiometrics()
|
||||
}
|
||||
|
||||
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> {
|
||||
@@ -74,11 +88,27 @@ class AppLockService: AppLockServiceProtocol {
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func enableBiometricUnlock() -> Result<Void, AppLockServiceError> {
|
||||
guard isEnabled else { return .failure(.pinNotSet) }
|
||||
guard let state = context.evaluatedPolicyDomainState else { return .failure(.biometricUnlockNotSupported) }
|
||||
|
||||
do {
|
||||
try keychainController.setPINCodeBiometricState(state)
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Keychain access error: \(error)")
|
||||
return .failure(.keychainError)
|
||||
}
|
||||
}
|
||||
|
||||
func disableBiometricUnlock() {
|
||||
keychainController.removePINCodeBiometricState()
|
||||
}
|
||||
|
||||
func disable() {
|
||||
biometricUnlockEnabled = false
|
||||
keychainController.removePINCode()
|
||||
keychainController.removePINCodeBiometricState()
|
||||
appSettings.appLockNumberOfPINAttempts = 0
|
||||
appSettings.appLockNumberOfBiometricAttempts = 0
|
||||
disabledSubject.send()
|
||||
}
|
||||
|
||||
@@ -96,35 +126,68 @@ class AppLockService: AppLockServiceProtocol {
|
||||
appSettings.appLockNumberOfPINAttempts += 1
|
||||
return false
|
||||
}
|
||||
|
||||
if biometricUnlockEnabled, !biometricUnlockTrusted {
|
||||
MXLog.info("Fixing trust for biometric unlock.")
|
||||
updateBiometrics()
|
||||
_ = enableBiometricUnlock()
|
||||
}
|
||||
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
guard biometryType != .none, biometricUnlockEnabled else {
|
||||
MXLog.warning("\(biometryType) failed.")
|
||||
appSettings.appLockNumberOfBiometricAttempts += 1
|
||||
MXLog.error("Biometric unlock not setup.")
|
||||
return false
|
||||
}
|
||||
|
||||
guard biometricUnlockTrusted else {
|
||||
MXLog.error("Biometrics have changed. PIN should be shown.")
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let context = unlockContext()
|
||||
guard try await context.evaluatePolicy(unlockPolicy, localizedReason: L10n.screenAppLockBiometricUnlockReasonIos) else {
|
||||
MXLog.warning("\(context.biometryType) failed without error.")
|
||||
return false
|
||||
}
|
||||
return completeUnlock()
|
||||
} catch {
|
||||
MXLog.error("\(context.biometryType) failed: \(error)")
|
||||
return false
|
||||
}
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Queries the context for supported biometrics.
|
||||
private func configureBiometrics() {
|
||||
/// Queries the context for supported biometrics and enrolment state.
|
||||
private func updateBiometrics() {
|
||||
var error: NSError?
|
||||
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
context.canEvaluatePolicy(unlockPolicy, error: &error)
|
||||
|
||||
if let error {
|
||||
MXLog.error("Biometrics error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared logic for completing an unlock via a PIN or biometry.
|
||||
/// Creates a context specifically for unlocking the app. The titles are customised,
|
||||
/// and the fresh context ensures that the user is promoted to unlock based on
|
||||
/// `timer.gracePeriod` rather than any system to defined grace period.
|
||||
private func unlockContext() -> LAContext {
|
||||
// Keep using the injected context for tests etc.
|
||||
guard type(of: context) == LAContext.self else { return context }
|
||||
|
||||
let context = LAContext()
|
||||
context.localizedFallbackTitle = L10n.actionEnterPin
|
||||
return context
|
||||
}
|
||||
|
||||
/// Shared logic for completing an unlock via a PIN or biometrics.
|
||||
private func completeUnlock() -> Bool {
|
||||
timer.registerUnlock()
|
||||
appSettings.appLockNumberOfPINAttempts = 0
|
||||
appSettings.appLockNumberOfBiometricAttempts = 0
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ enum AppLockServiceError: Error {
|
||||
case invalidPIN
|
||||
/// The PIN code was rejected as an insecure choice.
|
||||
case weakPIN
|
||||
/// A PIN code hasn't been set yet.
|
||||
case pinNotSet
|
||||
/// Attempting to use biometric unlock when it isn't yet supported on this device.
|
||||
case biometricUnlockNotSupported
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -36,7 +40,10 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
/// 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 }
|
||||
var biometricUnlockEnabled: Bool { get }
|
||||
/// Whether TouchID, FaceID or (possibly) OpticID are trusted, or if the app needs the user
|
||||
/// to re-enter their PIN code to re-enable the feature (i.e. to accept a new face or fingerprint).
|
||||
var biometricUnlockTrusted: Bool { get }
|
||||
|
||||
/// A publisher that advertises when the service has been disabled.
|
||||
var disabledPublisher: AnyPublisher<Void, Never> { get }
|
||||
@@ -45,6 +52,10 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
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>
|
||||
/// Enables the use of Touch ID/Face ID as an alternative to the PIN code.
|
||||
func enableBiometricUnlock() -> Result<Void, AppLockServiceError>
|
||||
/// Disables the use of Touch ID/Face ID as an alternative to the PIN code.
|
||||
func disableBiometricUnlock()
|
||||
/// Disables the App Lock feature, removing the user's stored PIN code.
|
||||
func disable()
|
||||
|
||||
@@ -56,12 +67,12 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
/// Attempt to unlock the app with the supplied PIN code.
|
||||
func unlock(with pinCode: String) -> Bool
|
||||
/// Attempt to unlock the app using FaceID or TouchID.
|
||||
func unlockWithBiometrics() -> Bool
|
||||
func unlockWithBiometrics() async -> Bool
|
||||
|
||||
/// The number of attempts the user had made to unlock with a PIN code.
|
||||
///
|
||||
/// Note: We don't track the biometric attempts as LAContext does that automatically.
|
||||
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
|
||||
@@ -73,7 +84,6 @@ extension 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 }
|
||||
|
||||
@@ -31,7 +31,7 @@ class AppLockTimer {
|
||||
/// Creates a new timer.
|
||||
/// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded.
|
||||
init(gracePeriod: TimeInterval) {
|
||||
self.gracePeriod = 180
|
||||
self.gracePeriod = gracePeriod
|
||||
}
|
||||
|
||||
/// Signals to the timer to track how long the app will be backgrounded for.
|
||||
|
||||
@@ -38,7 +38,8 @@ class KeychainController: KeychainControllerProtocol {
|
||||
private let mainKeychain: Keychain
|
||||
|
||||
private enum Key: String {
|
||||
case pinCode
|
||||
case appLockPINCode
|
||||
case appLockBiometricState
|
||||
}
|
||||
|
||||
init(service: KeychainControllerService, accessGroup: String) {
|
||||
@@ -129,16 +130,16 @@ class KeychainController: KeychainControllerProtocol {
|
||||
}
|
||||
|
||||
func containsPINCode() throws -> Bool {
|
||||
try mainKeychain.contains(Key.pinCode.rawValue)
|
||||
try mainKeychain.contains(Key.appLockPINCode.rawValue)
|
||||
}
|
||||
|
||||
func setPINCode(_ pinCode: String) throws {
|
||||
try mainKeychain.set(pinCode, key: Key.pinCode.rawValue)
|
||||
try mainKeychain.set(pinCode, key: Key.appLockPINCode.rawValue)
|
||||
}
|
||||
|
||||
func pinCode() -> String? {
|
||||
do {
|
||||
return try mainKeychain.getString(Key.pinCode.rawValue)
|
||||
return try mainKeychain.getString(Key.appLockPINCode.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving the PIN code.")
|
||||
return nil
|
||||
@@ -147,9 +148,39 @@ class KeychainController: KeychainControllerProtocol {
|
||||
|
||||
func removePINCode() {
|
||||
do {
|
||||
try mainKeychain.remove(Key.pinCode.rawValue)
|
||||
try mainKeychain.remove(Key.appLockPINCode.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed removing the PIN code.")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPINCodeBiometricState() -> Bool {
|
||||
do {
|
||||
return try mainKeychain.contains(Key.appLockBiometricState.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed checking for biometric state.")
|
||||
return false // No need to re-throw the error, we can fall back to the PIN code.
|
||||
}
|
||||
}
|
||||
|
||||
func setPINCodeBiometricState(_ state: Data) throws {
|
||||
try mainKeychain.set(state, key: Key.appLockBiometricState.rawValue)
|
||||
}
|
||||
|
||||
func pinCodeBiometricState() -> Data? {
|
||||
do {
|
||||
return try mainKeychain.getData(Key.appLockBiometricState.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed setting the PIN code biometric state.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removePINCodeBiometricState() {
|
||||
do {
|
||||
try mainKeychain.remove(Key.appLockBiometricState.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed removing the PIN code biometric state.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,12 @@ protocol KeychainControllerProtocol: ClientSessionDelegate {
|
||||
func pinCode() -> String?
|
||||
/// Removes the App Lock PIN code.
|
||||
func removePINCode()
|
||||
/// Whether or not PIN code biometric state has been set.
|
||||
func containsPINCodeBiometricState() -> Bool
|
||||
/// Sets the PIN code biometric state for App Lock.
|
||||
func setPINCodeBiometricState(_ state: Data) throws
|
||||
/// The PIN code biometric state required to use Touch/Face ID to unlock the app.
|
||||
func pinCodeBiometricState() -> Data?
|
||||
/// Removes the App Lock PIN code biometric state.
|
||||
func removePINCodeBiometricState()
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Face ID is used to access your app.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Grant location access so that $(APP_DISPLAY_NAME) can share your location.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -87,6 +87,7 @@ targets:
|
||||
NSMicrophoneUsageDescription: To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone.
|
||||
NSPhotoLibraryAddUsageDescription: Allows saving photos and videos to your library.
|
||||
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
|
||||
NSFaceIDUsageDescription: Face ID is used to access your app.
|
||||
UIBackgroundModes: [
|
||||
fetch,
|
||||
audio,
|
||||
|
||||
@@ -43,6 +43,7 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
// Given a valid PIN code.
|
||||
let pinCode = "2023"
|
||||
keychainController.pinCodeReturnValue = pinCode
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
|
||||
// When entering it on the lock screen.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
|
||||
@@ -69,6 +70,7 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
// Given an invalid PIN code.
|
||||
let pinCode = "2024"
|
||||
keychainController.pinCodeReturnValue = "2023"
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.")
|
||||
XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.")
|
||||
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
||||
@@ -95,6 +97,7 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
func testForceQuitRequiresLogout() {
|
||||
// Given an app with a PIN set where the user attempted to unlock 3 times.
|
||||
keychainController.pinCodeReturnValue = "2023"
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
appSettings.appLockNumberOfPINAttempts = 2
|
||||
XCTAssertNil(context.alertInfo)
|
||||
viewModel.context.pinCode = "0000"
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import LocalAuthentication
|
||||
import XCTest
|
||||
|
||||
@testable import ElementX
|
||||
|
||||
@MainActor
|
||||
class AppLockServiceTests: XCTestCase {
|
||||
var keychainController: KeychainController!
|
||||
var appSettings: AppSettings!
|
||||
var service: AppLockService!
|
||||
|
||||
@@ -28,7 +30,8 @@ class AppLockServiceTests: XCTestCase {
|
||||
appSettings = AppSettings()
|
||||
appSettings.appLockFlowEnabled = true
|
||||
|
||||
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
||||
keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
||||
keychainController.resetSecrets()
|
||||
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings)
|
||||
service.disable()
|
||||
@@ -38,6 +41,8 @@ class AppLockServiceTests: XCTestCase {
|
||||
AppSettings.reset()
|
||||
}
|
||||
|
||||
// MARK: - PIN Code
|
||||
|
||||
func testValidPINCode() {
|
||||
// Given a service that hasn't been enabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
@@ -176,6 +181,137 @@ class AppLockServiceTests: XCTestCase {
|
||||
XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.")
|
||||
}
|
||||
|
||||
// MARK: - Biometric Unlock
|
||||
|
||||
func testEnableBiometricUnlock() async {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
guard case .success = service.setupPINCode("2023") else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
|
||||
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should not be enabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should not be trusted.")
|
||||
|
||||
// When enabling biometric unlock.
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
context.evaluatePolicyReturnValue = true
|
||||
|
||||
// Then the service should be unlockable with biometrics.
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should now be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should now be trusted.")
|
||||
guard await service.unlockWithBiometrics() else {
|
||||
XCTFail("The biometric unlock should work.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func testBiometricUnlockTrust() {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
let pinCode = "2023"
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
|
||||
// When the user changes biometric data.
|
||||
context.evaluatedPolicyDomainStateValue = "👈".data(using: .utf8)
|
||||
|
||||
// Then biometric lock should remain enabled but untrusted.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
|
||||
// When the user confirms their PIN code.
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The PIN code should be accepted")
|
||||
|
||||
// Then the biometric lock should once again be trusted.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should once again be trusted.")
|
||||
}
|
||||
|
||||
func testDisableBiometricUnlock() {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
guard case .success = service.setupPINCode("2023") else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
|
||||
// When disabling biometric unlock.
|
||||
service.disableBiometricUnlock()
|
||||
|
||||
// Then only PIN unlock should remain enabled.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
}
|
||||
|
||||
func testDisablePINWithBiometricUnlock() {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
guard case .success = service.setupPINCode("2023") else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
|
||||
// When disabling the PIN lock.
|
||||
service.disable()
|
||||
|
||||
// Then both PIN and biometric unlock should be disabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
}
|
||||
|
||||
// MARK: - Attempt failures
|
||||
|
||||
func testResetAttemptsOnUnlock() {
|
||||
// Given a service that is enabled and has failed unlock attempts.
|
||||
let pinCode = "2023"
|
||||
@@ -184,9 +320,7 @@ class AppLockServiceTests: XCTestCase {
|
||||
return
|
||||
}
|
||||
appSettings.appLockNumberOfPINAttempts = 2
|
||||
appSettings.appLockNumberOfBiometricAttempts = 2
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
|
||||
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.")
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
|
||||
// When unlocking the service
|
||||
@@ -194,7 +328,6 @@ class AppLockServiceTests: XCTestCase {
|
||||
|
||||
// Then the attempts counts should both be reset.
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
|
||||
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
|
||||
}
|
||||
|
||||
func testResetAttemptsOnDisable() {
|
||||
@@ -205,9 +338,7 @@ class AppLockServiceTests: XCTestCase {
|
||||
return
|
||||
}
|
||||
appSettings.appLockNumberOfPINAttempts = 2
|
||||
appSettings.appLockNumberOfBiometricAttempts = 2
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
|
||||
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.")
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
|
||||
// When disabling the service
|
||||
@@ -216,6 +347,38 @@ class AppLockServiceTests: XCTestCase {
|
||||
|
||||
// Then the attempts counts should both be reset.
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
|
||||
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
/// A customised context that allows injecting a few mock values but otherwise behaves as expected.
|
||||
/// It works as the actual context does and won't update the return values of `biometryType` and
|
||||
/// `evaluatedPolicyDomainStateValue` until either `canEvaluatePolicy` or
|
||||
/// `evaluatePolicy` have been called.
|
||||
private class LAContextMock: LAContext {
|
||||
var biometryTypeValue: LABiometryType!
|
||||
private var internalBiometryTypeValue: LABiometryType!
|
||||
override var biometryType: LABiometryType { internalBiometryTypeValue }
|
||||
|
||||
var evaluatedPolicyDomainStateValue: Data?
|
||||
private var internalEvaluatedPolicyDomainStateValue: Data?
|
||||
override var evaluatedPolicyDomainState: Data? { internalEvaluatedPolicyDomainStateValue }
|
||||
|
||||
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
|
||||
let result = super.canEvaluatePolicy(policy, error: error)
|
||||
updateInternalValues()
|
||||
return result
|
||||
}
|
||||
|
||||
var evaluatePolicyReturnValue: Bool!
|
||||
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
|
||||
updateInternalValues()
|
||||
return evaluatePolicyReturnValue
|
||||
}
|
||||
|
||||
private func updateInternalValues() {
|
||||
internalBiometryTypeValue = biometryTypeValue
|
||||
internalEvaluatedPolicyDomainStateValue = evaluatedPolicyDomainStateValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,16 +20,18 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
|
||||
var appLockService: AppLockService!
|
||||
var keychainController: KeychainControllerMock!
|
||||
var appLockService: AppLockServiceMock!
|
||||
var viewModel: AppLockSetupBiometricsScreenViewModelProtocol!
|
||||
|
||||
var context: AppLockSetupBiometricsScreenViewModelType.Context { viewModel.context }
|
||||
|
||||
override func setUp() {
|
||||
AppSettings.reset()
|
||||
keychainController = KeychainControllerMock()
|
||||
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
|
||||
|
||||
appLockService = AppLockServiceMock()
|
||||
appLockService.underlyingIsEnabled = true
|
||||
appLockService.underlyingBiometryType = .touchID
|
||||
appLockService.enableBiometricUnlockReturnValue = .success(())
|
||||
viewModel = AppLockSetupBiometricsScreenViewModel(appLockService: appLockService)
|
||||
}
|
||||
|
||||
@@ -38,24 +40,22 @@ class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testAllow() async throws {
|
||||
// Given a service that has biometric unlock disabled.
|
||||
XCTAssertFalse(appLockService.biometricUnlockEnabled)
|
||||
|
||||
// When allowing Touch/Face ID.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
|
||||
context.send(viewAction: .allow)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should now have biometric unlock enabled.
|
||||
XCTAssertTrue(appLockService.biometricUnlockEnabled)
|
||||
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 1)
|
||||
}
|
||||
|
||||
func testSkip() async throws {
|
||||
// Given a service that has biometric unlock disabled.
|
||||
XCTAssertFalse(appLockService.biometricUnlockEnabled)
|
||||
|
||||
// When skipping biometrics.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
|
||||
context.send(viewAction: .skip)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should now have biometric unlock enabled.
|
||||
XCTAssertFalse(appLockService.biometricUnlockEnabled)
|
||||
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
let pinCode = "2023"
|
||||
keychainController.pinCodeReturnValue = pinCode
|
||||
keychainController.containsPINCodeReturnValue = true
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
|
||||
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
||||
context.pinCode = pinCode
|
||||
|
||||
@@ -123,7 +123,7 @@ class KeychainControllerTests: XCTestCase {
|
||||
// When setting a PIN code.
|
||||
try keychain.setPINCode("0000")
|
||||
|
||||
// The the PIN code should be stored.
|
||||
// Then the PIN code should be stored.
|
||||
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.")
|
||||
XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.")
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class KeychainControllerTests: XCTestCase {
|
||||
// When setting a different PIN code.
|
||||
try keychain.setPINCode("1234")
|
||||
|
||||
// The the PIN code should be updated.
|
||||
// Then the PIN code should be updated.
|
||||
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should still contain the PIN code.")
|
||||
XCTAssertEqual(keychain.pinCode(), "1234", "The stored PIN code should match the new value.")
|
||||
}
|
||||
@@ -151,8 +151,54 @@ class KeychainControllerTests: XCTestCase {
|
||||
// When removing the PIN code.
|
||||
keychain.removePINCode()
|
||||
|
||||
// The the PIN code should no longer be stored.
|
||||
// Then the PIN code should no longer be stored.
|
||||
try XCTAssertFalse(keychain.containsPINCode(), "The keychain should no longer contain the PIN code.")
|
||||
XCTAssertNil(keychain.pinCode(), "There shouldn't be a stored PIN code after removing it.")
|
||||
}
|
||||
|
||||
func testAddPINCodeBiometricState() throws {
|
||||
// Given a keychain without any biometric state.
|
||||
XCTAssertFalse(keychain.containsPINCodeBiometricState(), "A new keychain shouldn't contain biometric state.")
|
||||
XCTAssertNil(keychain.pinCodeBiometricState(), "A new keychain shouldn't return biometric state.")
|
||||
|
||||
// When setting the state.
|
||||
let data: Data! = "Face ID".data(using: .utf8)
|
||||
try keychain.setPINCodeBiometricState(data)
|
||||
|
||||
// Then the state should be stored.
|
||||
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.")
|
||||
XCTAssertEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state should match what was set.")
|
||||
}
|
||||
|
||||
func testUpdatePINCodeBiometricState() throws {
|
||||
// Given a keychain that contains PIN code biometric state.
|
||||
let data: Data! = "😃".data(using: .utf8)
|
||||
try keychain.setPINCodeBiometricState(data)
|
||||
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.")
|
||||
XCTAssertEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state should match what was set.")
|
||||
|
||||
// When setting different state.
|
||||
let newData: Data! = "😎".data(using: .utf8)
|
||||
try keychain.setPINCodeBiometricState(newData)
|
||||
|
||||
// Then the state should be updated.
|
||||
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should still contain biometric state.")
|
||||
XCTAssertNotEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state shouldn't match the old value.")
|
||||
XCTAssertEqual(keychain.pinCodeBiometricState(), newData, "The stored biometric state should match the new value.")
|
||||
}
|
||||
|
||||
func testRemovePINCodeBiometricState() throws {
|
||||
// Given a keychain that contains PIN code biometric state.
|
||||
let data: Data! = "Face ID".data(using: .utf8)
|
||||
try keychain.setPINCodeBiometricState(data)
|
||||
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.")
|
||||
XCTAssertEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state should match what was set.")
|
||||
|
||||
// When removing the state.
|
||||
keychain.removePINCodeBiometricState()
|
||||
|
||||
// Then the state should no longer be stored.
|
||||
XCTAssertFalse(keychain.containsPINCodeBiometricState(), "The keychain should no longer contain the biometric state.")
|
||||
XCTAssertNil(keychain.pinCodeBiometricState(), "There shouldn't be any stored biometric state after removing it.")
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/pr-1966.wip
Normal file
1
changelog.d/pr-1966.wip
Normal file
@@ -0,0 +1 @@
|
||||
Add support for Face ID/Touch ID to app lock.
|
||||
Reference in New Issue
Block a user