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 2eb8945824
commit 11563c6297
27 changed files with 598 additions and 92 deletions
+9 -9
View File
@@ -70,6 +70,7 @@
126EE01D8BEAEF26105D83C5 /* RoomDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */; }; 126EE01D8BEAEF26105D83C5 /* RoomDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */; };
12C867E85E6D12EEDFD0B127 /* CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C4762F8D6112E43117DB2F /* CustomStringConvertible.swift */; }; 12C867E85E6D12EEDFD0B127 /* CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C4762F8D6112E43117DB2F /* CustomStringConvertible.swift */; };
12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC3F82523D6F48B926D6AF68 /* AppSettings.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 */; }; 13853973A5E24374FCEDE8A3 /* RedactedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */; };
13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; }; 13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; };
13CBC470FB619A6393A21908 /* RoomNotificationSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.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 */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; };
9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; }; 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; };
9F19096BFA629C0AC282B1E4 /* CreateRoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.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 */; }; 9FAF6DA7E8E85C9699757764 /* CollapsibleRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2656184491C505700D2405 /* CollapsibleRoomTimelineView.swift */; };
9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */; }; 9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */; };
A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */; }; A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */; };
A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; }; A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; };
A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */; }; A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */; };
A0D7E5BD0298A97DCBDCE40B /* Prefire in Frameworks */ = {isa = PBXBuildFile; productRef = 2629CF48B33643CD5F69C612 /* Prefire */; }; 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 */; }; A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */; };
A14A9419105A1CD42F0511C4 /* UserIndicatorModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */; }; A14A9419105A1CD42F0511C4 /* UserIndicatorModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43005941B3A2C9671E23C85 /* UserIndicatorModalView.swift */; };
A17FAD2EBC53E17B5FD384DB /* InviteUsersScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22730A30C50AC2E3D5BA8642 /* InviteUsersScreenViewModelProtocol.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 */; }; A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; };
A743841F91B62B0E56217B04 /* SecureBackupKeyBackupScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DCB219D7B7B0299358FF81 /* SecureBackupKeyBackupScreenUITests.swift */; }; A743841F91B62B0E56217B04 /* SecureBackupKeyBackupScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DCB219D7B7B0299358FF81 /* SecureBackupKeyBackupScreenUITests.swift */; };
A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.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 */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; };
A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; };
A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = "<group>"; };
@@ -2433,6 +2433,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */, 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */,
62011D547772F3DF5D924823 /* ApplicationMock.swift */,
382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */,
D38391154120264910D19528 /* PollMock.swift */, D38391154120264910D19528 /* PollMock.swift */,
36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */, 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */,
@@ -2443,7 +2444,6 @@
AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */, AAD01F7FC2BBAC7351948595 /* UserProfile+Mock.swift */,
B23135B06B044CB811139D2F /* Generated */, B23135B06B044CB811139D2F /* Generated */,
E5E545F92D01588360A9BAC5 /* SDK */, E5E545F92D01588360A9BAC5 /* SDK */,
A7BACE672AE97D4500FFBBEA /* ApplicationMock.swift */,
); );
path = Mocks; path = Mocks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -3859,7 +3859,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */, AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */,
A0FAB2B92AE92AB5008F20B3 /* VoiceMessageButton.swift */, B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */,
FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */, FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */,
94028A227645FA880B966211 /* WaveformSource.swift */, 94028A227645FA880B966211 /* WaveformSource.swift */,
03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */, 03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */,
@@ -5295,7 +5295,6 @@
0F6C8033FA60CFD36F7CA205 /* AppLockSetupPINScreenViewModel.swift in Sources */, 0F6C8033FA60CFD36F7CA205 /* AppLockSetupPINScreenViewModel.swift in Sources */,
4C356F5CCB4CDC99BFA45185 /* AppLockSetupPINScreenViewModelProtocol.swift in Sources */, 4C356F5CCB4CDC99BFA45185 /* AppLockSetupPINScreenViewModelProtocol.swift in Sources */,
4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */, 4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */,
A7BACE682AE97D4500FFBBEA /* ApplicationMock.swift in Sources */,
8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */, 8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */,
6E4E401BE97AC241DA7C7716 /* AppLockSetupSettingsScreenModels.swift in Sources */, 6E4E401BE97AC241DA7C7716 /* AppLockSetupSettingsScreenModels.swift in Sources */,
4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */, 4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */,
@@ -5304,6 +5303,7 @@
355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */, 355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */,
12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */, 12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */,
9462C62798F47E39DCC182D2 /* Application.swift in Sources */, 9462C62798F47E39DCC182D2 /* Application.swift in Sources */,
9F30A18B50D13B10D8444984 /* ApplicationMock.swift in Sources */,
74604ACFDBE7F54260E7B617 /* ApplicationProtocol.swift in Sources */, 74604ACFDBE7F54260E7B617 /* ApplicationProtocol.swift in Sources */,
61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */, 61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */,
90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */, 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */,
@@ -5577,7 +5577,6 @@
C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */, C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */,
AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */, AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */,
53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */, 53A59720F4729D9BBFFB7CAB /* NotificationSettingsEditScreenCoordinator.swift in Sources */,
A0FAB2BA2AE92AB5008F20B3 /* VoiceMessageButton.swift in Sources */,
4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */, 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */,
119AE9A3FC6E0606C1146528 /* NotificationSettingsEditScreenRoomCell.swift in Sources */, 119AE9A3FC6E0606C1146528 /* NotificationSettingsEditScreenRoomCell.swift in Sources */,
D5FE90A6AF5FD5AE91BD37C7 /* NotificationSettingsEditScreenViewModel.swift in Sources */, D5FE90A6AF5FD5AE91BD37C7 /* NotificationSettingsEditScreenViewModel.swift in Sources */,
@@ -5877,6 +5876,7 @@
1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */, 1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */,
64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */, 64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */,
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */, 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */,
1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */,
4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */, 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */,
4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */, 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */,
386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */, 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */,
@@ -33,6 +33,7 @@
"action_edit" = "Edit"; "action_edit" = "Edit";
"action_enable" = "Enable"; "action_enable" = "Enable";
"action_end_poll" = "End poll"; "action_end_poll" = "End poll";
"action_enter_pin" = "Enter PIN";
"action_forgot_password" = "Forgot password?"; "action_forgot_password" = "Forgot password?";
"action_forward" = "Forward"; "action_forward" = "Forward";
"action_invite" = "Invite"; "action_invite" = "Invite";
@@ -277,6 +278,7 @@
"screen_analytics_settings_share_data" = "Share analytics data"; "screen_analytics_settings_share_data" = "Share analytics data";
"screen_app_lock_biometric_authentication" = "biometric authentication"; "screen_app_lock_biometric_authentication" = "biometric authentication";
"screen_app_lock_biometric_unlock" = "biometric unlock"; "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_forgot_pin" = "Forgot PIN?";
"screen_app_lock_settings_change_pin" = "Change PIN code"; "screen_app_lock_settings_change_pin" = "Change PIN code";
"screen_app_lock_settings_enable_biometric_unlock" = "Allow biometric unlock"; "screen_app_lock_settings_enable_biometric_unlock" = "Allow biometric unlock";
@@ -291,7 +291,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
if oldVersion < Version(1, 1, 0) { if oldVersion < Version(1, 1, 0) {
MXLog.info("Migrating to v1.1.0, signing out the user.") MXLog.info("Migrating to v1.1.0, signing out the user.")
// Version 1.1.0 switched the Rust crypto store to SQLite // 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() wipeUserData()
} }
@@ -308,6 +308,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
private func wipeUserData(includingSettings: Bool = false) { private func wipeUserData(includingSettings: Bool = false) {
if includingSettings { if includingSettings {
AppSettings.reset() AppSettings.reset()
appLockFlowCoordinator.appLockService.disable()
} }
userSessionStore.reset() userSessionStore.reset()
} }
@@ -133,9 +133,6 @@ final class AppSettings {
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked). /// 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)) @UserPreference(key: UserDefaultsKeys.appLockNumberOfPINAttempts, defaultValue: 0, storageType: .userDefaults(store))
var appLockNumberOfPINAttempts: Int 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 // MARK: - Authentication
@@ -32,6 +32,8 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
let userIndicatorController: UserIndicatorController let userIndicatorController: UserIndicatorController
let navigationCoordinator: NavigationRootCoordinator 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 var cancellables: Set<AnyCancellable> = []
private let actionsSubject: PassthroughSubject<AppLockFlowCoordinatorAction, Never> = .init() private let actionsSubject: PassthroughSubject<AppLockFlowCoordinatorAction, Never> = .init()
@@ -44,6 +46,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.navigationCoordinator = navigationCoordinator self.navigationCoordinator = navigationCoordinator
// Set the initial background state.
showPlaceholder()
appLockService.disabledPublisher appLockService.disabledPublisher
.sink { [weak self] _ in .sink { [weak self] _ in
// When the service is disabled via a force logout, we need to remove the activity indicator. // 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 // MARK: - App unlock
private func applicationDidEnterBackground() { private func applicationDidEnterBackground() {
unlockTask = nil
guard appLockService.isEnabled else { return } guard appLockService.isEnabled else { return }
appLockService.applicationDidEnterBackground() appLockService.applicationDidEnterBackground()
@@ -80,12 +87,33 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
private func applicationWillEnterForeground() { private func applicationWillEnterForeground() {
guard appLockService.isEnabled else { return } guard appLockService.isEnabled else { return }
if appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) { guard appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else {
showUnlockScreen()
} else {
// Reveal the app again if within the grace period. // Reveal the app again if within the grace period.
actionsSubject.send(.unlockApp) 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. /// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher.
@@ -140,6 +140,9 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
showBiometricsPrompt() showBiometricsPrompt()
case (.createPIN, .settings): case (.createPIN, .settings):
navigationStackCoordinator.setSheetCoordinator(nil) 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): case (.biometricsPrompt, .settings):
showSettings() showSettings()
case (.settings, .createPIN): case (.settings, .createPIN):
+4
View File
@@ -82,6 +82,8 @@ public enum L10n {
public static var actionEnable: String { return L10n.tr("Localizable", "action_enable") } public static var actionEnable: String { return L10n.tr("Localizable", "action_enable") }
/// End poll /// End poll
public static var actionEndPoll: String { return L10n.tr("Localizable", "action_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? /// Forgot password?
public static var actionForgotPassword: String { return L10n.tr("Localizable", "action_forgot_password") } public static var actionForgotPassword: String { return L10n.tr("Localizable", "action_forgot_password") }
/// Forward /// Forward
@@ -676,6 +678,8 @@ public enum L10n {
public static var screenAppLockBiometricAuthentication: String { return L10n.tr("Localizable", "screen_app_lock_biometric_authentication") } public static var screenAppLockBiometricAuthentication: String { return L10n.tr("Localizable", "screen_app_lock_biometric_authentication") }
/// biometric unlock /// biometric unlock
public static var screenAppLockBiometricUnlock: String { return L10n.tr("Localizable", "screen_app_lock_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? /// Forgot PIN?
public static var screenAppLockForgotPin: String { return L10n.tr("Localizable", "screen_app_lock_forgot_pin") } public static var screenAppLockForgotPin: String { return L10n.tr("Localizable", "screen_app_lock_forgot_pin") }
/// Change PIN code /// Change PIN code
@@ -137,6 +137,11 @@ class AppLockServiceMock: AppLockServiceProtocol {
set(value) { underlyingBiometricUnlockEnabled = value } set(value) { underlyingBiometricUnlockEnabled = value }
} }
var underlyingBiometricUnlockEnabled: Bool! var underlyingBiometricUnlockEnabled: Bool!
var biometricUnlockTrusted: Bool {
get { return underlyingBiometricUnlockTrusted }
set(value) { underlyingBiometricUnlockTrusted = value }
}
var underlyingBiometricUnlockTrusted: Bool!
var disabledPublisher: AnyPublisher<Void, Never> { var disabledPublisher: AnyPublisher<Void, Never> {
get { return underlyingDisabledPublisher } get { return underlyingDisabledPublisher }
set(value) { underlyingDisabledPublisher = value } set(value) { underlyingDisabledPublisher = value }
@@ -147,11 +152,6 @@ class AppLockServiceMock: AppLockServiceProtocol {
set(value) { underlyingNumberOfPINAttempts = value } set(value) { underlyingNumberOfPINAttempts = value }
} }
var underlyingNumberOfPINAttempts: AnyPublisher<Int, Never>! var underlyingNumberOfPINAttempts: AnyPublisher<Int, Never>!
var numberOfBiometricAttempts: AnyPublisher<Int, Never> {
get { return underlyingNumberOfBiometricAttempts }
set(value) { underlyingNumberOfBiometricAttempts = value }
}
var underlyingNumberOfBiometricAttempts: AnyPublisher<Int, Never>!
//MARK: - setupPINCode //MARK: - setupPINCode
@@ -195,6 +195,35 @@ class AppLockServiceMock: AppLockServiceProtocol {
return validateReturnValue 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 //MARK: - disable
var disableCallsCount = 0 var disableCallsCount = 0
@@ -268,12 +297,12 @@ class AppLockServiceMock: AppLockServiceProtocol {
return unlockWithBiometricsCallsCount > 0 return unlockWithBiometricsCallsCount > 0
} }
var unlockWithBiometricsReturnValue: Bool! var unlockWithBiometricsReturnValue: Bool!
var unlockWithBiometricsClosure: (() -> Bool)? var unlockWithBiometricsClosure: (() async -> Bool)?
func unlockWithBiometrics() -> Bool { func unlockWithBiometrics() async -> Bool {
unlockWithBiometricsCallsCount += 1 unlockWithBiometricsCallsCount += 1
if let unlockWithBiometricsClosure = unlockWithBiometricsClosure { if let unlockWithBiometricsClosure = unlockWithBiometricsClosure {
return unlockWithBiometricsClosure() return await unlockWithBiometricsClosure()
} else { } else {
return unlockWithBiometricsReturnValue return unlockWithBiometricsReturnValue
} }
@@ -906,6 +935,72 @@ class KeychainControllerMock: KeychainControllerProtocol {
removePINCodeCallsCount += 1 removePINCodeCallsCount += 1
removePINCodeClosure?() 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 { class MediaPlayerMock: MediaPlayerProtocol {
var mediaSource: MediaSourceProxy? var mediaSource: MediaSourceProxy?
@@ -46,11 +46,7 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
switch viewAction { switch viewAction {
case .submitPINCode: case .submitPINCode:
guard appLockService.unlock(with: state.bindings.pinCode) else { submitPINCode()
handleInvalidPIN()
return
}
actionsSubject.send(.appUnlocked)
case .clearPINCode: case .clearPINCode:
state.bindings.pinCode = "" state.bindings.pinCode = ""
case .forgotPIN: case .forgotPIN:
@@ -60,6 +56,14 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
// MARK: - Private // MARK: - Private
private func submitPINCode() {
guard appLockService.unlock(with: state.bindings.pinCode) else {
handleInvalidPIN()
return
}
actionsSubject.send(.appUnlocked)
}
private func handleForgotPIN() { private func handleForgotPIN() {
state.bindings.alertInfo = .init(id: .confirmResetPIN, state.bindings.alertInfo = .init(id: .confirmResetPIN,
title: L10n.screenAppLockSignoutAlertTitle, title: L10n.screenAppLockSignoutAlertTitle,
@@ -39,13 +39,34 @@ class AppLockSetupBiometricsScreenViewModel: AppLockSetupBiometricsScreenViewMod
switch viewAction { switch viewAction {
case .allow: case .allow:
MXLog.info("Enable biometric unlock.") Task { await enableBiometricUnlock() }
appLockService.biometricUnlockEnabled = true
actionsSubject.send(.continue)
case .skip: case .skip:
MXLog.info("Disable biometric unlock.") disableBiometricUnlock()
appLockService.biometricUnlockEnabled = false }
}
// 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) actionsSubject.send(.continue)
} }
} }
}
@@ -30,8 +30,8 @@ struct AppLockSetupSettingsScreenViewState: BindableState {
let biometryType: LABiometryType let biometryType: LABiometryType
var bindings: AppLockSetupSettingsScreenViewStateBindings var bindings: AppLockSetupSettingsScreenViewStateBindings
var supportsBiometry: Bool { biometryType != .none } var supportsBiometrics: Bool { biometryType != .none }
var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) } var enableBiometricsTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
} }
struct AppLockSetupSettingsScreenViewStateBindings { struct AppLockSetupSettingsScreenViewStateBindings {
@@ -45,12 +45,34 @@ class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelTy
case .disable: case .disable:
showRemovePINAlert() showRemovePINAlert()
case .enableBiometricsChanged: case .enableBiometricsChanged:
appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics Task { await toggleBiometrics() }
} }
} }
// MARK: - Private // 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. /// Shows a confirmation alert to the user before removing their PIN code.
private func showRemovePINAlert() { private func showRemovePINAlert() {
state.bindings.alertInfo = .init(id: .confirmRemovePINCode, state.bindings.alertInfo = .init(id: .confirmRemovePINCode,
@@ -32,9 +32,9 @@ struct AppLockSetupSettingsScreen: View {
} }
} }
if context.viewState.supportsBiometry { if context.viewState.supportsBiometrics {
Section { Section {
ListRow(label: .plain(title: context.viewState.enableBiometryTitle), ListRow(label: .plain(title: context.viewState.enableBiometricsTitle),
kind: .toggle($context.enableBiometrics)) kind: .toggle($context.enableBiometrics))
.onChange(of: context.enableBiometrics) { _ in .onChange(of: context.enableBiometrics) { _ in
context.send(viewAction: .enableBiometricsChanged) context.send(viewAction: .enableBiometricsChanged)
@@ -22,7 +22,7 @@ extension LABiometryType {
var systemSymbol: SFSymbol { var systemSymbol: SFSymbol {
switch self { switch self {
case .none: case .none:
MXLog.error("Invalid presentation: Biometry not supported.") MXLog.error("Invalid presentation: Biometrics not supported.")
return .viewfinder return .viewfinder
case .touchID: case .touchID:
return .touchid return .touchid
@@ -40,7 +40,7 @@ extension LABiometryType {
var localizedString: String { var localizedString: String {
switch self { switch self {
case .none: case .none:
MXLog.error("Invalid presentation: Biometry not supported.") MXLog.error("Invalid presentation: Biometrics not supported.")
return L10n.screenAppLockBiometricUnlock return L10n.screenAppLockBiometricUnlock
case .touchID: case .touchID:
return L10n.commonTouchIdIos return L10n.commonTouchIdIos
@@ -21,9 +21,10 @@ import LocalAuthentication
class AppLockService: AppLockServiceProtocol { class AppLockService: AppLockServiceProtocol {
private let keychainController: KeychainControllerProtocol private let keychainController: KeychainControllerProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
private let context = LAContext() private let context: LAContext
private let timer: AppLockTimer private let timer: AppLockTimer
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
var isMandatory: Bool { appSettings.appLockIsMandatory } var isMandatory: Bool { appSettings.appLockIsMandatory }
@@ -38,21 +39,34 @@ class AppLockService: AppLockServiceProtocol {
} }
} }
var biometryType: LABiometryType { context.biometryType } var biometryType: LABiometryType {
var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet. 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 numberOfPINAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfPINAttempts }
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfBiometricAttempts }
private var disabledSubject: PassthroughSubject<Void, Never> = .init() private var disabledSubject: PassthroughSubject<Void, Never> = .init()
var disabledPublisher: AnyPublisher<Void, Never> { disabledSubject.eraseToAnyPublisher() } var disabledPublisher: AnyPublisher<Void, Never> { disabledSubject.eraseToAnyPublisher() }
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) { init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) {
self.keychainController = keychainController self.keychainController = keychainController
self.appSettings = appSettings self.appSettings = appSettings
self.context = context
timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod) timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod)
configureBiometrics() updateBiometrics()
} }
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> { func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> {
@@ -74,11 +88,27 @@ class AppLockService: AppLockServiceProtocol {
return .success(()) 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() { func disable() {
biometricUnlockEnabled = false
keychainController.removePINCode() keychainController.removePINCode()
keychainController.removePINCodeBiometricState()
appSettings.appLockNumberOfPINAttempts = 0 appSettings.appLockNumberOfPINAttempts = 0
appSettings.appLockNumberOfBiometricAttempts = 0
disabledSubject.send() disabledSubject.send()
} }
@@ -96,35 +126,68 @@ class AppLockService: AppLockServiceProtocol {
appSettings.appLockNumberOfPINAttempts += 1 appSettings.appLockNumberOfPINAttempts += 1
return false return false
} }
if biometricUnlockEnabled, !biometricUnlockTrusted {
MXLog.info("Fixing trust for biometric unlock.")
updateBiometrics()
_ = enableBiometricUnlock()
}
return completeUnlock() return completeUnlock()
} }
func unlockWithBiometrics() -> Bool { func unlockWithBiometrics() async -> Bool {
guard biometryType != .none, biometricUnlockEnabled else { guard biometryType != .none, biometricUnlockEnabled else {
MXLog.warning("\(biometryType) failed.") MXLog.error("Biometric unlock not setup.")
appSettings.appLockNumberOfBiometricAttempts += 1 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 false
} }
return completeUnlock() return completeUnlock()
} catch {
MXLog.error("\(context.biometryType) failed: \(error)")
return false
}
} }
// MARK: - Private // MARK: - Private
/// Queries the context for supported biometrics. /// Queries the context for supported biometrics and enrolment state.
private func configureBiometrics() { private func updateBiometrics() {
var error: NSError? var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) context.canEvaluatePolicy(unlockPolicy, error: &error)
if let error { if let error {
MXLog.error("Biometrics error: \(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 { private func completeUnlock() -> Bool {
timer.registerUnlock() timer.registerUnlock()
appSettings.appLockNumberOfPINAttempts = 0 appSettings.appLockNumberOfPINAttempts = 0
appSettings.appLockNumberOfBiometricAttempts = 0
return true return true
} }
} }
@@ -24,6 +24,10 @@ enum AppLockServiceError: Error {
case invalidPIN case invalidPIN
/// The PIN code was rejected as an insecure choice. /// The PIN code was rejected as an insecure choice.
case weakPIN 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 @MainActor
@@ -36,7 +40,10 @@ protocol AppLockServiceProtocol: AnyObject {
/// The type of biometric authentication supported by the device. /// The type of biometric authentication supported by the device.
var biometryType: LABiometryType { get } var biometryType: LABiometryType { get }
/// Whether or not the user has enabled unlock via TouchID, FaceID or (possibly) OpticID. /// 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. /// A publisher that advertises when the service has been disabled.
var disabledPublisher: AnyPublisher<Void, Never> { get } var disabledPublisher: AnyPublisher<Void, Never> { get }
@@ -45,6 +52,10 @@ protocol AppLockServiceProtocol: AnyObject {
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError>
/// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice. /// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice.
func validate(_ pinCode: String) -> Result<Void, AppLockServiceError> 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. /// Disables the App Lock feature, removing the user's stored PIN code.
func disable() func disable()
@@ -56,12 +67,12 @@ protocol AppLockServiceProtocol: AnyObject {
/// Attempt to unlock the app with the supplied PIN code. /// Attempt to unlock the app with the supplied PIN code.
func unlock(with pinCode: String) -> Bool func unlock(with pinCode: String) -> Bool
/// Attempt to unlock the app using FaceID or TouchID. /// 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. /// 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 } 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 // sourcery: AutoMockable
@@ -73,7 +84,6 @@ extension AppLockServiceMock {
mock.isEnabled = pinCode != nil mock.isEnabled = pinCode != nil
mock.isMandatory = isMandatory mock.isMandatory = isMandatory
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher() mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
mock.numberOfBiometricAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
mock.underlyingBiometryType = biometryType mock.underlyingBiometryType = biometryType
mock.underlyingBiometricUnlockEnabled = biometryType != .none mock.underlyingBiometricUnlockEnabled = biometryType != .none
mock.unlockWithClosure = { $0 == pinCode } mock.unlockWithClosure = { $0 == pinCode }
@@ -31,7 +31,7 @@ class AppLockTimer {
/// Creates a new timer. /// Creates a new timer.
/// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded. /// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded.
init(gracePeriod: TimeInterval) { init(gracePeriod: TimeInterval) {
self.gracePeriod = 180 self.gracePeriod = gracePeriod
} }
/// Signals to the timer to track how long the app will be backgrounded for. /// Signals to the timer to track how long the app will be backgrounded for.
@@ -38,7 +38,8 @@ class KeychainController: KeychainControllerProtocol {
private let mainKeychain: Keychain private let mainKeychain: Keychain
private enum Key: String { private enum Key: String {
case pinCode case appLockPINCode
case appLockBiometricState
} }
init(service: KeychainControllerService, accessGroup: String) { init(service: KeychainControllerService, accessGroup: String) {
@@ -129,16 +130,16 @@ class KeychainController: KeychainControllerProtocol {
} }
func containsPINCode() throws -> Bool { func containsPINCode() throws -> Bool {
try mainKeychain.contains(Key.pinCode.rawValue) try mainKeychain.contains(Key.appLockPINCode.rawValue)
} }
func setPINCode(_ pinCode: String) throws { func setPINCode(_ pinCode: String) throws {
try mainKeychain.set(pinCode, key: Key.pinCode.rawValue) try mainKeychain.set(pinCode, key: Key.appLockPINCode.rawValue)
} }
func pinCode() -> String? { func pinCode() -> String? {
do { do {
return try mainKeychain.getString(Key.pinCode.rawValue) return try mainKeychain.getString(Key.appLockPINCode.rawValue)
} catch { } catch {
MXLog.error("Failed retrieving the PIN code.") MXLog.error("Failed retrieving the PIN code.")
return nil return nil
@@ -147,9 +148,39 @@ class KeychainController: KeychainControllerProtocol {
func removePINCode() { func removePINCode() {
do { do {
try mainKeychain.remove(Key.pinCode.rawValue) try mainKeychain.remove(Key.appLockPINCode.rawValue)
} catch { } catch {
MXLog.error("Failed removing the PIN code.") MXLog.error("Failed removing the PIN code.")
} }
} }
func containsPINCodeBiometricState() -> Bool {
do {
return try mainKeychain.contains(Key.appLockBiometricState.rawValue)
} catch {
MXLog.error("Failed checking for biometric state.")
return false // No need to re-throw the error, we can fall back to the PIN code.
}
}
func setPINCodeBiometricState(_ state: Data) throws {
try mainKeychain.set(state, key: Key.appLockBiometricState.rawValue)
}
func pinCodeBiometricState() -> Data? {
do {
return try mainKeychain.getData(Key.appLockBiometricState.rawValue)
} catch {
MXLog.error("Failed setting the PIN code biometric state.")
return nil
}
}
func removePINCodeBiometricState() {
do {
try mainKeychain.remove(Key.appLockBiometricState.rawValue)
} catch {
MXLog.error("Failed removing the PIN code biometric state.")
}
}
} }
@@ -44,4 +44,12 @@ protocol KeychainControllerProtocol: ClientSessionDelegate {
func pinCode() -> String? func pinCode() -> String?
/// Removes the App Lock PIN code. /// Removes the App Lock PIN code.
func removePINCode() 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()
} }
+2
View File
@@ -66,6 +66,8 @@
<false/> <false/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera.</string> <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> <key>NSLocationWhenInUseUsageDescription</key>
<string>Grant location access so that $(APP_DISPLAY_NAME) can share your location.</string> <string>Grant location access so that $(APP_DISPLAY_NAME) can share your location.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
+1
View File
@@ -87,6 +87,7 @@ targets:
NSMicrophoneUsageDescription: To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone. 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. NSPhotoLibraryAddUsageDescription: Allows saving photos and videos to your library.
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location. NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
NSFaceIDUsageDescription: Face ID is used to access your app.
UIBackgroundModes: [ UIBackgroundModes: [
fetch, fetch,
audio, audio,
@@ -43,6 +43,7 @@ class AppLockScreenViewModelTests: XCTestCase {
// Given a valid PIN code. // Given a valid PIN code.
let pinCode = "2023" let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeBiometricStateReturnValue = false
// When entering it on the lock screen. // When entering it on the lock screen.
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked } let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
@@ -69,6 +70,7 @@ class AppLockScreenViewModelTests: XCTestCase {
// Given an invalid PIN code. // Given an invalid PIN code.
let pinCode = "2024" let pinCode = "2024"
keychainController.pinCodeReturnValue = "2023" keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeBiometricStateReturnValue = false
XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.") XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.")
XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.") XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.")
XCTAssertNil(context.alertInfo, "No alert should be shown yet.") XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
@@ -95,6 +97,7 @@ class AppLockScreenViewModelTests: XCTestCase {
func testForceQuitRequiresLogout() { func testForceQuitRequiresLogout() {
// Given an app with a PIN set where the user attempted to unlock 3 times. // Given an app with a PIN set where the user attempted to unlock 3 times.
keychainController.pinCodeReturnValue = "2023" keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeBiometricStateReturnValue = false
appSettings.appLockNumberOfPINAttempts = 2 appSettings.appLockNumberOfPINAttempts = 2
XCTAssertNil(context.alertInfo) XCTAssertNil(context.alertInfo)
viewModel.context.pinCode = "0000" viewModel.context.pinCode = "0000"
@@ -14,12 +14,14 @@
// limitations under the License. // limitations under the License.
// //
import LocalAuthentication
import XCTest import XCTest
@testable import ElementX @testable import ElementX
@MainActor @MainActor
class AppLockServiceTests: XCTestCase { class AppLockServiceTests: XCTestCase {
var keychainController: KeychainController!
var appSettings: AppSettings! var appSettings: AppSettings!
var service: AppLockService! var service: AppLockService!
@@ -28,7 +30,8 @@ class AppLockServiceTests: XCTestCase {
appSettings = AppSettings() appSettings = AppSettings()
appSettings.appLockFlowEnabled = true 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 = AppLockService(keychainController: keychainController, appSettings: appSettings)
service.disable() service.disable()
@@ -38,6 +41,8 @@ class AppLockServiceTests: XCTestCase {
AppSettings.reset() AppSettings.reset()
} }
// MARK: - PIN Code
func testValidPINCode() { func testValidPINCode() {
// Given a service that hasn't been enabled. // Given a service that hasn't been enabled.
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.") 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.") 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() { func testResetAttemptsOnUnlock() {
// Given a service that is enabled and has failed unlock attempts. // Given a service that is enabled and has failed unlock attempts.
let pinCode = "2023" let pinCode = "2023"
@@ -184,9 +320,7 @@ class AppLockServiceTests: XCTestCase {
return return
} }
appSettings.appLockNumberOfPINAttempts = 2 appSettings.appLockNumberOfPINAttempts = 2
appSettings.appLockNumberOfBiometricAttempts = 2
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.") 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.") XCTAssertTrue(service.isEnabled, "The service should be enabled.")
// When unlocking the service // When unlocking the service
@@ -194,7 +328,6 @@ class AppLockServiceTests: XCTestCase {
// Then the attempts counts should both be reset. // Then the attempts counts should both be reset.
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.") XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
} }
func testResetAttemptsOnDisable() { func testResetAttemptsOnDisable() {
@@ -205,9 +338,7 @@ class AppLockServiceTests: XCTestCase {
return return
} }
appSettings.appLockNumberOfPINAttempts = 2 appSettings.appLockNumberOfPINAttempts = 2
appSettings.appLockNumberOfBiometricAttempts = 2
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.") 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.") XCTAssertTrue(service.isEnabled, "The service should be enabled.")
// When disabling the service // When disabling the service
@@ -216,6 +347,38 @@ class AppLockServiceTests: XCTestCase {
// Then the attempts counts should both be reset. // Then the attempts counts should both be reset.
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.") XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.") }
}
// MARK: - Mocks
/// A customised context that allows injecting a few mock values but otherwise behaves as expected.
/// It works as the actual context does and won't update the return values of `biometryType` and
/// `evaluatedPolicyDomainStateValue` until either `canEvaluatePolicy` or
/// `evaluatePolicy` have been called.
private class LAContextMock: LAContext {
var biometryTypeValue: LABiometryType!
private var internalBiometryTypeValue: LABiometryType!
override var biometryType: LABiometryType { internalBiometryTypeValue }
var evaluatedPolicyDomainStateValue: Data?
private var internalEvaluatedPolicyDomainStateValue: Data?
override var evaluatedPolicyDomainState: Data? { internalEvaluatedPolicyDomainStateValue }
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
let result = super.canEvaluatePolicy(policy, error: error)
updateInternalValues()
return result
}
var evaluatePolicyReturnValue: Bool!
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
updateInternalValues()
return evaluatePolicyReturnValue
}
private func updateInternalValues() {
internalBiometryTypeValue = biometryTypeValue
internalEvaluatedPolicyDomainStateValue = evaluatedPolicyDomainStateValue
} }
} }
@@ -20,16 +20,18 @@ import XCTest
@MainActor @MainActor
class AppLockSetupBiometricsScreenViewModelTests: XCTestCase { class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
var appLockService: AppLockService! var appLockService: AppLockServiceMock!
var keychainController: KeychainControllerMock!
var viewModel: AppLockSetupBiometricsScreenViewModelProtocol! var viewModel: AppLockSetupBiometricsScreenViewModelProtocol!
var context: AppLockSetupBiometricsScreenViewModelType.Context { viewModel.context } var context: AppLockSetupBiometricsScreenViewModelType.Context { viewModel.context }
override func setUp() { override func setUp() {
AppSettings.reset() 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) viewModel = AppLockSetupBiometricsScreenViewModel(appLockService: appLockService)
} }
@@ -38,24 +40,22 @@ class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
} }
func testAllow() async throws { func testAllow() async throws {
// Given a service that has biometric unlock disabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
// When allowing Touch/Face ID. // When allowing Touch/Face ID.
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
context.send(viewAction: .allow) context.send(viewAction: .allow)
try await deferred.fulfill()
// Then the service should now have biometric unlock enabled. // Then the service should now have biometric unlock enabled.
XCTAssertTrue(appLockService.biometricUnlockEnabled) XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 1)
} }
func testSkip() async throws { func testSkip() async throws {
// Given a service that has biometric unlock disabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
// When skipping biometrics. // When skipping biometrics.
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
context.send(viewAction: .skip) context.send(viewAction: .skip)
try await deferred.fulfill()
// Then the service should now have biometric unlock enabled. // Then the service should now have biometric unlock enabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled) XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 0)
} }
} }
@@ -87,6 +87,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
let pinCode = "2023" let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeReturnValue = true keychainController.containsPINCodeReturnValue = true
keychainController.containsPINCodeBiometricStateReturnValue = false
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete } let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
context.pinCode = pinCode context.pinCode = pinCode
@@ -123,7 +123,7 @@ class KeychainControllerTests: XCTestCase {
// When setting a PIN code. // When setting a PIN code.
try keychain.setPINCode("0000") 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.") try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.") 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. // When setting a different PIN code.
try keychain.setPINCode("1234") 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.") 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.") 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. // When removing the PIN code.
keychain.removePINCode() 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.") 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.") 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
View File
@@ -0,0 +1 @@
Add support for Face ID/Touch ID to app lock.