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:
Doug
2023-10-27 10:09:12 +01:00
committed by GitHub
parent 36cf0a0442
commit 5bb85257e2
27 changed files with 598 additions and 92 deletions

View File

@@ -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 */,

View File

@@ -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";

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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):

View File

@@ -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

View File

@@ -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?

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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 }

View File

@@ -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.

View File

@@ -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.")
}
}
}

View File

@@ -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()
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
Add support for Face ID/Touch ID to app lock.