mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 04:18:31 +00:00
Add support for Face ID/Touch ID to app lock. (#1966)
* Fix biometrics with low grace period and backgrounding before unlocked. * Trigger permissions alert when enabling Face ID.
This commit is contained in:
@@ -70,6 +70,7 @@
|
|||||||
126EE01D8BEAEF26105D83C5 /* RoomDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */; };
|
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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+27
-6
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
+23
-1
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Add support for Face ID/Touch ID to app lock.
|
||||||
Reference in New Issue
Block a user