diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 12661cbc0..29a01eda9 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 035177BCD8E8308B098AC3C2 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; 0376C429FAB1687C3D905F3E /* MockCoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCoder.swift; sourceTree = ""; }; - 0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = test_voice_message.m4a; sourceTree = ""; }; + 0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */ = {isa = PBXFileReference; path = test_voice_message.m4a; sourceTree = ""; }; 03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformViewDragGestureModifier.swift; sourceTree = ""; }; 03DD998E523D4EC93C7ED703 /* RoomNotificationSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModelTests.swift; sourceTree = ""; }; @@ -1364,6 +1364,7 @@ 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyProtocol.swift; sourceTree = ""; }; 60F18AECC9D38C2B6D85F99C /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; 612EF972F2A1800682D32C5E /* StickerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerRoomTimelineView.swift; sourceTree = ""; }; + 62011D547772F3DF5D924823 /* ApplicationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationMock.swift; sourceTree = ""; }; 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreMapView.swift; sourceTree = ""; }; 62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderTests.swift; sourceTree = ""; }; 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveQuickLook.swift; sourceTree = ""; }; @@ -1567,7 +1568,6 @@ A05707BF550D770168A406DB /* LoginViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModelTests.swift; sourceTree = ""; }; A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = ""; }; A0A01AECFF54281CF35909A6 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; - A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = ""; }; A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = ""; }; A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = ""; }; A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = ""; }; @@ -1588,7 +1588,6 @@ A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriverProtocol.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; - A7BACE672AE97D4500FFBBEA /* ApplicationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationMock.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; @@ -1658,6 +1657,7 @@ B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = ""; }; B81B6170DB690013CEB646F4 /* MapLibreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreModels.swift; sourceTree = ""; }; B83BC0DC9A2DF2DD60F9B6E9 /* NotificationSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenUITests.swift; sourceTree = ""; }; + B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = ""; }; B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregratedReaction.swift; sourceTree = ""; }; B8A3B7637DDBD6AA97AC2545 /* CameraPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPicker.swift; sourceTree = ""; }; B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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 */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 0b728350b..6761d1403 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -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"; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 6eb982a3f..9ae092247 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -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() } diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 59a186008..044e6c752 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -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 diff --git a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift index 2f84fd031..e5c393675 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift @@ -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? private var cancellables: Set = [] private let actionsSubject: PassthroughSubject = .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. diff --git a/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift index 767f868a6..177d6e95b 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift @@ -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): diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 942868cbc..0d0036c48 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -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 diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 5053f0918..1e3beed04 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -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 { get { return underlyingDisabledPublisher } set(value) { underlyingDisabledPublisher = value } @@ -147,11 +152,6 @@ class AppLockServiceMock: AppLockServiceProtocol { set(value) { underlyingNumberOfPINAttempts = value } } var underlyingNumberOfPINAttempts: AnyPublisher! - var numberOfBiometricAttempts: AnyPublisher { - get { return underlyingNumberOfBiometricAttempts } - set(value) { underlyingNumberOfBiometricAttempts = value } - } - var underlyingNumberOfBiometricAttempts: AnyPublisher! //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! + var enableBiometricUnlockClosure: (() -> Result)? + + func enableBiometricUnlock() -> Result { + 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? diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift index 7d78959dd..a568d68c7 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift @@ -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, diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/AppLockSetupBiometricsScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/AppLockSetupBiometricsScreenViewModel.swift index 9948ac0f1..65481406c 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/AppLockSetupBiometricsScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/AppLockSetupBiometricsScreenViewModel.swift @@ -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) + } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift index b83cf036b..064588d0e 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift @@ -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 { diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift index 30fe7c7c1..14113ee0a 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift @@ -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, diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift index a69f22ec4..b4049019a 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift @@ -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) diff --git a/ElementX/Sources/Screens/AppLock/Common/LABiometryType.swift b/ElementX/Sources/Screens/AppLock/Common/LABiometryType.swift index ada09d03e..71c6158e9 100644 --- a/ElementX/Sources/Screens/AppLock/Common/LABiometryType.swift +++ b/ElementX/Sources/Screens/AppLock/Common/LABiometryType.swift @@ -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 diff --git a/ElementX/Sources/Services/AppLock/AppLockService.swift b/ElementX/Sources/Services/AppLock/AppLockService.swift index 12792cda5..0c186c510 100644 --- a/ElementX/Sources/Services/AppLock/AppLockService.swift +++ b/ElementX/Sources/Services/AppLock/AppLockService.swift @@ -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 { appSettings.$appLockNumberOfPINAttempts } - var numberOfBiometricAttempts: AnyPublisher { appSettings.$appLockNumberOfBiometricAttempts } private var disabledSubject: PassthroughSubject = .init() var disabledPublisher: AnyPublisher { 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 { @@ -74,11 +88,27 @@ class AppLockService: AppLockServiceProtocol { return .success(()) } + func enableBiometricUnlock() -> Result { + 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 } } diff --git a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift index c58343298..8026e20b0 100644 --- a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift +++ b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift @@ -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 { get } @@ -45,6 +52,10 @@ protocol AppLockServiceProtocol: AnyObject { func setupPINCode(_ pinCode: String) -> Result /// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice. func validate(_ pinCode: String) -> Result + /// Enables the use of Touch ID/Face ID as an alternative to the PIN code. + func enableBiometricUnlock() -> Result + /// 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 { get } - /// The number of attempts the user has made to unlock with Touch/Face ID. - var numberOfBiometricAttempts: AnyPublisher { get } } // sourcery: AutoMockable @@ -73,7 +84,6 @@ extension AppLockServiceMock { mock.isEnabled = pinCode != nil mock.isMandatory = isMandatory mock.numberOfPINAttempts = PassthroughSubject().eraseToAnyPublisher() - mock.numberOfBiometricAttempts = PassthroughSubject().eraseToAnyPublisher() mock.underlyingBiometryType = biometryType mock.underlyingBiometricUnlockEnabled = biometryType != .none mock.unlockWithClosure = { $0 == pinCode } diff --git a/ElementX/Sources/Services/AppLock/AppLockTimer.swift b/ElementX/Sources/Services/AppLock/AppLockTimer.swift index 9df2fcede..59ada94f8 100644 --- a/ElementX/Sources/Services/AppLock/AppLockTimer.swift +++ b/ElementX/Sources/Services/AppLock/AppLockTimer.swift @@ -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. diff --git a/ElementX/Sources/Services/Keychain/KeychainController.swift b/ElementX/Sources/Services/Keychain/KeychainController.swift index 99e87eabe..4e279a97e 100644 --- a/ElementX/Sources/Services/Keychain/KeychainController.swift +++ b/ElementX/Sources/Services/Keychain/KeychainController.swift @@ -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.") + } + } } diff --git a/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift b/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift index f77813af9..280c3a5fb 100644 --- a/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift +++ b/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift @@ -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() } diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index a41670afa..d098c182f 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -66,6 +66,8 @@ NSCameraUsageDescription To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera. + NSFaceIDUsageDescription + Face ID is used to access your app. NSLocationWhenInUseUsageDescription Grant location access so that $(APP_DISPLAY_NAME) can share your location. NSMicrophoneUsageDescription diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 845132bc5..ab43ea33e 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -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, diff --git a/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift index da35d3157..46a69c01a 100644 --- a/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift @@ -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" diff --git a/UnitTests/Sources/AppLock/AppLockServiceTests.swift b/UnitTests/Sources/AppLock/AppLockServiceTests.swift index b69799671..7613b55ef 100644 --- a/UnitTests/Sources/AppLock/AppLockServiceTests.swift +++ b/UnitTests/Sources/AppLock/AppLockServiceTests.swift @@ -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 } } diff --git a/UnitTests/Sources/AppLock/AppLockSetupBiometricsScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockSetupBiometricsScreenViewModelTests.swift index 3393e8500..41bb24623 100644 --- a/UnitTests/Sources/AppLock/AppLockSetupBiometricsScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockSetupBiometricsScreenViewModelTests.swift @@ -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) } } diff --git a/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift index 276a09638..1be4058ed 100644 --- a/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift @@ -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 diff --git a/UnitTests/Sources/KeychainControllerTests.swift b/UnitTests/Sources/KeychainControllerTests.swift index 6c9aee623..3f9913087 100644 --- a/UnitTests/Sources/KeychainControllerTests.swift +++ b/UnitTests/Sources/KeychainControllerTests.swift @@ -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.") + } } diff --git a/changelog.d/pr-1966.wip b/changelog.d/pr-1966.wip new file mode 100644 index 000000000..fd2a1c95a --- /dev/null +++ b/changelog.d/pr-1966.wip @@ -0,0 +1 @@ +Add support for Face ID/Touch ID to app lock. \ No newline at end of file