From 8ac2829cd0fc5929e8819d6b6479b126acca45f6 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Wed, 25 Oct 2023 12:03:37 +0100 Subject: [PATCH] Initial implementation for AppLockSetupFlowCoordinator. (#1949) Rename AppLockSettingsScreen to AppLockSetupSettingsScreen. Implement forced logout and persist PIN entry attempts. --- ElementX.xcodeproj/project.pbxproj | 80 +++--- .../en.lproj/Localizable.strings | 2 +- .../en.lproj/Localizable.stringsdict | 16 ++ .../Sources/Application/AppCoordinator.swift | 37 ++- .../AppCoordinatorStateMachine.swift | 13 +- .../Sources/Application/AppSettings.swift | 12 +- .../AppLockFlowCoordinator.swift | 18 +- .../AppLockSetupFlowCoordinator.swift | 260 ++++++++++++++++++ ElementX/Sources/Generated/Strings.swift | 8 +- .../Mocks/Generated/GeneratedMocks.swift | 20 ++ .../AppLockScreenCoordinator.swift | 4 + .../AppLockScreen/AppLockScreenModels.swift | 8 +- .../AppLockScreenViewModel.swift | 27 +- .../View/AppLockSetupBiometricsScreen.swift | 4 +- .../AppLockSetupPINScreenCoordinator.swift | 7 +- .../AppLockSetupPINScreenModels.swift | 2 + .../AppLockSetupPINScreenViewModel.swift | 4 +- .../View/AppLockSetupPINScreen.swift | 32 ++- ...pLockSetupSettingsScreenCoordinator.swift} | 31 ++- .../AppLockSetupSettingsScreenModels.swift} | 20 +- ...AppLockSetupSettingsScreenViewModel.swift} | 19 +- ...etupSettingsScreenViewModelProtocol.swift} | 6 +- .../View/AppLockSetupSettingsScreen.swift} | 29 +- .../AuthenticationCoordinator.swift | 49 +++- .../SettingsScreenCoordinator.swift | 28 +- .../Services/AppLock/AppLockService.swift | 26 +- .../AppLock/AppLockServiceProtocol.swift | 17 +- .../UITests/UITestsAppCoordinator.swift | 16 +- .../UITests/UITestsScreenIdentifier.swift | 1 - .../AppLockSettingsScreenUITests.swift | 26 -- ...n-GB-iPad-9th-generation.appLockScreen.png | 4 +- ...d-9th-generation.appLockSettingsScreen.png | 3 - ...iPad-9th-generation.appLockSetupFlow-0.png | 4 +- .../en-GB-iPhone-14.appLockScreen.png | 4 +- .../en-GB-iPhone-14.appLockSettingsScreen.png | 3 - .../en-GB-iPhone-14.appLockSetupFlow-0.png | 4 +- ...eudo-iPad-9th-generation.appLockScreen.png | 4 +- ...d-9th-generation.appLockSettingsScreen.png | 3 - ...iPad-9th-generation.appLockSetupFlow-0.png | 4 +- .../pseudo-iPhone-14.appLockScreen.png | 4 +- ...pseudo-iPhone-14.appLockSettingsScreen.png | 3 - .../pseudo-iPhone-14.appLockSetupFlow-0.png | 4 +- .../AppLock/AppLockScreenViewModelTests.swift | 60 +++- .../Sources/AppLock/AppLockServiceTests.swift | 43 +++ .../AppLockSettingsScreenViewModelTests.swift | 8 +- .../AppLockSetupPINScreenViewModelTests.swift | 8 +- .../test_appLockSetupPINScreen.Confirm.png | 4 +- .../test_appLockSetupPINScreen.Create.png | 4 +- .../test_appLockSetupPINScreen.Unlock.png | 4 +- ...est_appLockSetupSettingsScreen.Face-ID.png | 3 + ...st_appLockSetupSettingsScreen.PIN-only.png | 3 + ...SetupSettingsScreen.Touch-ID-Mandatory.png | 3 + changelog.d/pr-1949.wip | 1 + 53 files changed, 773 insertions(+), 234 deletions(-) create mode 100644 ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift rename ElementX/Sources/Screens/AppLock/{AppLockSettingsScreen/AppLockSettingsScreenCoordinator.swift => AppLockSetupSettingsScreen/AppLockSetupSettingsScreenCoordinator.swift} (53%) rename ElementX/Sources/Screens/AppLock/{AppLockSettingsScreen/AppLockSettingsScreenModels.swift => AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift} (70%) rename ElementX/Sources/Screens/AppLock/{AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift => AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift} (70%) rename ElementX/Sources/Screens/AppLock/{AppLockSettingsScreen/AppLockSettingsScreenViewModelProtocol.swift => AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModelProtocol.swift} (74%) rename ElementX/Sources/Screens/AppLock/{AppLockSettingsScreen/View/AppLockSettingsScreen.swift => AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift} (58%) delete mode 100644 UITests/Sources/AppLockSettingsScreenUITests.swift delete mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSettingsScreen.png delete mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSettingsScreen.png delete mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSettingsScreen.png delete mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSettingsScreen.png create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Face-ID.png create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.PIN-only.png create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Touch-ID-Mandatory.png create mode 100644 changelog.d/pr-1949.wip diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d772b0385..6e5baf479 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 000765812BABB81F5174C601 /* AppLockSetupPINScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEDC38E389B89BCF5C1AFD4A /* AppLockSetupPINScreenUITests.swift */; }; 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C070FD43DC6BF4E50217965A /* LocalizationTests.swift */; }; 0180C44B997EDA8D21F883AC /* RoomNotificationSettingsCustomSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B746EFA112532A7B701FB914 /* RoomNotificationSettingsCustomSectionView.swift */; }; - 0206016CCEF6EF9365916768 /* AppLockSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33284693F54382F46CFD2EDD /* AppLockSettingsScreenViewModelProtocol.swift */; }; 020C530986D7B97631877FEF /* TimelineItemMacContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */; }; 020F7E70167FB2833266F2F0 /* AnalyticsSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D39D7F513A36C9C1951DB44C /* AnalyticsSettingsScreen.swift */; }; 024E70451A7CD9E4E034D8A9 /* VoiceMessageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */; }; @@ -45,7 +44,6 @@ 0A194F5E70B5A628C1BF4476 /* AdvancedSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */; }; 0AA0477E063E72B786A983CF /* AnalyticsPromptScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF2DA52B1DE7CAEC5422 /* AnalyticsPromptScreenViewModel.swift */; }; 0ACAA31FD0399CEEBA3ECC21 /* UserDetailsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */; }; - 0AD81E04A8C024C09B7AEAC5 /* AppLockSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86F43BF1C26CB31B4BFA610 /* AppLockSettingsScreenModels.swift */; }; 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */; }; 0B57C2399B9E1CE5CE0D8005 /* ComposerToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D121B4FCFC38DBCC17BCC6D6 /* ComposerToolbar.swift */; }; 0BDA19079FD6E17C5AC62E22 /* RoomDetailsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */; }; @@ -197,7 +195,6 @@ 355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */; }; 3582056513A384F110EC8274 /* MediaPlayerProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7A2C4A3A74F0D2FFE9356A /* MediaPlayerProviderTests.swift */; }; 35E975CFDA60E05362A7CF79 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 1222DB76B917EB8A55365BA5 /* target.yml */; }; - 3627DFEE96824E0E2EA69B88 /* AppLockSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6615CBDE154455007F456DBB /* AppLockSettingsScreen.swift */; }; 366D5BFE52CB79E804C7D095 /* CallScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD9547E47C58930E2CE8306 /* CallScreenViewModelTests.swift */; }; 368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */; }; 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; @@ -265,6 +262,7 @@ 4714991754A08B58B4D7ED85 /* OnboardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F27BAB69EB568369F1F6B3 /* OnboardingScreenViewModelProtocol.swift */; }; 47305C0911C9E1AA774A4000 /* TemplateScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */; }; 4799A852132F1744E2825994 /* CreateRoomViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340179A0FC1AD4AEDA7FC134 /* CreateRoomViewModelProtocol.swift */; }; + 4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */; }; 484202C5D50983442D24D061 /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; 491D62ACD19E6F134B1766AF /* RoomNotificationSettingsUserDefinedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */; }; 492274DA6691EE985C2FCCAA /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; }; @@ -282,6 +280,7 @@ 4C356F5CCB4CDC99BFA45185 /* AppLockSetupPINScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7884BD256C091EB511B2EDF /* AppLockSetupPINScreenViewModelProtocol.swift */; }; 4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */; }; 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */; }; + 4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */; }; 4E8F17EBA24FBBA6ABB62ECB /* MockBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3948D16F021DFDB2CD26EAA8 /* MockBackgroundTaskService.swift */; }; 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; }; @@ -352,7 +351,6 @@ 6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */; }; 61941DEE5F3834765770BE01 /* InviteUsersScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */; }; 61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E93A1BE7D8A2EBCAD51EEB4 /* Array.swift */; }; - 61C345258DD392477E79A3B5 /* AppLockSettingsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F088B61525099A48909743B /* AppLockSettingsScreenUITests.swift */; }; 6213C897001F953E21D3CC16 /* CompletionSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF54536699ACEE3DB6BA3CB /* CompletionSuggestionService.swift */; }; 62418EA4E3EB597AD184AEB6 /* PillConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8D34E94AB07128DB73D6C7 /* PillConstants.swift */; }; 62910B515BCB4B455E24D7C1 /* AdvancedSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D086854995173E897F993C26 /* AdvancedSettingsScreenViewModelProtocol.swift */; }; @@ -400,6 +398,7 @@ 6CD61FAF03E8986523C2ABB8 /* StartChatScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */; }; 6D046D653DA28ADF1E6E59A4 /* BackgroundTaskServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE73D571D4F9C36DD45255A /* BackgroundTaskServiceProtocol.swift */; }; 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */; }; + 6E4E401BE97AC241DA7C7716 /* AppLockSetupSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502F986D57158674172C58E3 /* AppLockSetupSettingsScreenModels.swift */; }; 6E63704717F17593A475D152 /* RoomNotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */; }; 6EC7A40A537CFB3D526A111C /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47EBB5D698CE9A25BB553A2D /* Strings.swift */; }; 6F26CBC84AE87EB4068D398B /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = 78B28D75FF7AF8E6146DEE2A /* LRUCache */; }; @@ -480,7 +479,6 @@ 829062DD3C3F7016FE1A6476 /* RoomDetailsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFDAF6918BB096C44788FC9 /* RoomDetailsScreenUITests.swift */; }; 8317E1314C00DCCC99D30DA8 /* TextBasedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9227F7495DA43324050A863 /* TextBasedRoomTimelineItem.swift */; }; 83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; }; - 84215E902C62E9B8E8AB79F0 /* AppLockSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C0279BB8D907E2C40DDCA /* AppLockSettingsScreenCoordinator.swift */; }; 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; 8478992479B296C45150208F /* AppLockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */; }; 847DE3A7EB9FCA2C429C6E85 /* PINTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1D4A6D451F43A03CACD01D /* PINTextField.swift */; }; @@ -532,6 +530,7 @@ 8D3E1FADD78E72504DE0E402 /* UserAgentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */; }; 8D605456793F243649EC96AA /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = CD6B0C4639E066915B5E6463 /* target.yml */; }; 8D71E5E53F372202379BECCE /* BugReportScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */; }; + 8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9FCE4D1E3A81AC1CC5CB91 /* AppLockSetupSettingsScreenCoordinator.swift */; }; 8DDC6F28C797D8685F2F8E32 /* AnalyticsConsentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B6B383F1FD04CC0E7B60C6 /* AnalyticsConsentState.swift */; }; 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */; }; 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */; }; @@ -635,7 +634,6 @@ A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; }; A743841F91B62B0E56217B04 /* SecureBackupKeyBackupScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DCB219D7B7B0299358FF81 /* SecureBackupKeyBackupScreenUITests.swift */; }; A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; }; - A7BEE8216B4B12BE4C0F2C3F /* AppLockSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892EF45CCC5D2BF0FD1F770C /* AppLockSettingsScreenViewModel.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; }; @@ -673,6 +671,7 @@ B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B06663F7858E45882E63471 /* StaticLocationScreen.swift */; }; B1387648C6F71F1B98244803 /* SecureBackupRecoveryKeyScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596AA8843AC1A234F3387767 /* SecureBackupRecoveryKeyScreenCoordinator.swift */; }; B14BC354E56616B6B7D9A3D7 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */; }; + B188D0907A4D38AAAF6FEFA8 /* AppLockSetupFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DBB08A95EFA668F2CF27211 /* AppLockSetupFlowCoordinator.swift */; }; B22D857D1E8FCA6DD74A58E3 /* UserSessionScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */; }; B245583C63F8F90357B87FAE /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = A2AE110B053B55E38F8D10C7 /* KZFileWatchers */; }; B27D3190784F85916DA1C394 /* SessionVerificationScreenStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1EE0908B2BF9212436AD3E /* SessionVerificationScreenStateMachine.swift */; }; @@ -766,6 +765,7 @@ C7774720A4B2E34693E3227C /* RoomNotificationSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */; }; C7ABEBECDC513F7887DACF66 /* ProgressMaskModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68010886142843705E342645 /* ProgressMaskModifier.swift */; }; C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */; }; + C85C7A201E4CFDA477ACEBEB /* AppLockSetupSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8610C1D21565C950BCA6A454 /* AppLockSetupSettingsScreenViewModelProtocol.swift */; }; C8BD80891BAD688EF2C15CDB /* MediaUploadPreviewScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74DD0855F2F76D47E5555082 /* MediaUploadPreviewScreenCoordinator.swift */; }; C8E0FA0FF2CD6613264FA6B9 /* MessageForwardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */; }; C97325EFDCCEE457432A9E82 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0B4A34E69BD2132BEC521 /* MessageText.swift */; }; @@ -1066,6 +1066,7 @@ 0CCC6C31102E1D8B9106DEDE /* AppLockSetupBiometricsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelProtocol.swift; sourceTree = ""; }; 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenModels.swift; sourceTree = ""; }; 0D8F620C8B314840D8602E3F /* NSE.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NSE.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0DBB08A95EFA668F2CF27211 /* AppLockSetupFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupFlowCoordinator.swift; sourceTree = ""; }; 0DF5CBAF69BDF5DF31C661E1 /* IntentionalMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentionalMentions.swift; sourceTree = ""; }; 0E8BDC092D817B68CD9040C5 /* UserSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStore.swift; sourceTree = ""; }; 0EA689E792E679F5E3956F21 /* UITimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITimelineView.swift; sourceTree = ""; }; @@ -1131,6 +1132,7 @@ 1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = ""; }; 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 1D8866FE1CCCF10305FCACBC /* CallScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallScreenUITests.swift; sourceTree = ""; }; + 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenViewModel.swift; sourceTree = ""; }; 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = ""; }; 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelProtocol.swift; sourceTree = ""; }; 1DFE0E493FB55E5A62E7852A /* ProposedViewSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposedViewSize.swift; sourceTree = ""; }; @@ -1159,7 +1161,6 @@ 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelProtocol.swift; sourceTree = ""; }; 25F7FE40EF7490A7E09D7BE6 /* NotificationItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItemProxy.swift; sourceTree = ""; }; 260004737C573A56FA01E86E /* Encodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Encodable.swift; sourceTree = ""; }; - 267C0279BB8D907E2C40DDCA /* AppLockSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenCoordinator.swift; sourceTree = ""; }; 26B0A96B8FE4849227945067 /* VoiceMessageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorder.swift; sourceTree = ""; }; 26EAAB54C6CE91D64B69A9F8 /* AppLockServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockServiceProtocol.swift; sourceTree = ""; }; 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; @@ -1200,7 +1201,6 @@ 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsUserDefinedScreen.swift; sourceTree = ""; }; 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyProtocol.swift; sourceTree = ""; }; 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutViewModelTests.swift; sourceTree = ""; }; - 33284693F54382F46CFD2EDD /* AppLockSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 33649299575BADC34924ABC6 /* InvitesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenCoordinator.swift; sourceTree = ""; }; 33720F7AD25E85E4A84669E8 /* MapTilerGeocoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerGeocoding.swift; sourceTree = ""; }; 33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; @@ -1237,6 +1237,7 @@ 3D1D4A6D451F43A03CACD01D /* PINTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PINTextField.swift; sourceTree = ""; }; 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModelTests.swift; sourceTree = ""; }; 3D4DD336905C72F95EAF34B7 /* ElementX-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ElementX-Bridging-Header.h"; sourceTree = ""; }; + 3D9FCE4D1E3A81AC1CC5CB91 /* AppLockSetupSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenCoordinator.swift; sourceTree = ""; }; 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingTests.swift; sourceTree = ""; }; 3DF1FFC3336EB23374BBBFCC /* UIKitBackgroundTaskService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTaskService.swift; sourceTree = ""; }; 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineItem.swift; sourceTree = ""; }; @@ -1305,6 +1306,7 @@ 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = ""; }; 4FD6E621CC5E6D4830D96D2D /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = ""; }; 4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyProtocol.swift; sourceTree = ""; }; + 502F986D57158674172C58E3 /* AppLockSetupSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenModels.swift; sourceTree = ""; }; 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = ""; }; 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineItem.swift; sourceTree = ""; }; 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixUserShareLink.swift; sourceTree = ""; }; @@ -1323,6 +1325,7 @@ 536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 542D4F49FABA056DEEEB3400 /* RustTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustTracing.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreen.swift; sourceTree = ""; }; 54C4E7B46099462F12000C91 /* DeveloperOptionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModelProtocol.swift; sourceTree = ""; }; 55AEEF8142DF1B59DB40FB93 /* TimelineItemSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemSender.swift; sourceTree = ""; }; 55F30E764BED111C81739844 /* SoftLogoutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutUITests.swift; sourceTree = ""; }; @@ -1355,7 +1358,6 @@ 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetection.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; - 5F088B61525099A48909743B /* AppLockSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenUITests.swift; sourceTree = ""; }; 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionProtocol.swift; sourceTree = ""; }; 5F8002D0392A476D2758B291 /* AnalyticsPromptScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreen.swift; sourceTree = ""; }; 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyProtocol.swift; sourceTree = ""; }; @@ -1375,7 +1377,6 @@ 6569593FA36B22259E806A67 /* AudioRecorderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderState.swift; sourceTree = ""; }; 65AAD845E53B0C8B5E0812C2 /* UserDiscoveryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryService.swift; sourceTree = ""; }; 65C2B80DD0BF6F10BB5FA922 /* MockAuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthenticationServiceProxy.swift; sourceTree = ""; }; - 6615CBDE154455007F456DBB /* AppLockSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreen.swift; sourceTree = ""; }; 6654859746B0BE9611459391 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = cs; path = cs.lproj/Localizable.stringsdict; sourceTree = ""; }; 667DD3A9D932D7D9EB380CAA /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = sk; path = sk.lproj/Localizable.stringsdict; sourceTree = ""; }; 66901977F6469D03C333DF32 /* RoomNotificationSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenUITests.swift; sourceTree = ""; }; @@ -1481,6 +1482,7 @@ 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelTests.swift; sourceTree = ""; }; 854BCEAF2A832176FAACD2CB /* SplashScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenCoordinator.swift; sourceTree = ""; }; 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = ""; }; + 8610C1D21565C950BCA6A454 /* AppLockSetupSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 86376BEE425704AEE197CA54 /* PillContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContext.swift; sourceTree = ""; }; 86873A768B13069BB5CAECF6 /* InvitesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenViewModelProtocol.swift; sourceTree = ""; }; 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModel.swift; sourceTree = ""; }; @@ -1491,7 +1493,6 @@ 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerProtocol.swift; sourceTree = ""; }; 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerStateTests.swift; sourceTree = ""; }; 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; - 892EF45CCC5D2BF0FD1F770C /* AppLockSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenViewModel.swift; sourceTree = ""; }; 893777A4997BBDB68079D4F5 /* ArrayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayTests.swift; sourceTree = ""; }; 8977176AB534AA41630395BC /* LegalInformationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenViewModelProtocol.swift; sourceTree = ""; }; 897DF5E9A70CE05A632FC8AF /* UTType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTType.swift; sourceTree = ""; }; @@ -1655,7 +1656,6 @@ B81B6170DB690013CEB646F4 /* MapLibreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreModels.swift; sourceTree = ""; }; B83BC0DC9A2DF2DD60F9B6E9 /* NotificationSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenUITests.swift; sourceTree = ""; }; B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregratedReaction.swift; sourceTree = ""; }; - B86F43BF1C26CB31B4BFA610 /* AppLockSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenModels.swift; sourceTree = ""; }; B8A3B7637DDBD6AA97AC2545 /* CameraPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPicker.swift; sourceTree = ""; }; B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = ""; }; B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; @@ -2022,14 +2022,6 @@ path = VoiceMessage; sourceTree = ""; }; - 01FACE97F4C9E9F522D401EB /* View */ = { - isa = PBXGroup; - children = ( - 6615CBDE154455007F456DBB /* AppLockSettingsScreen.swift */, - ); - path = View; - sourceTree = ""; - }; 0210F4932B59277E2EEEF7BC /* RoomNotificationSettingsScreen */ = { isa = PBXGroup; children = ( @@ -2211,9 +2203,9 @@ isa = PBXGroup; children = ( 3AD37D7DDF9904587601239D /* AppLockScreen */, - CE39C9B97963CC30AB0859E5 /* AppLockSettingsScreen */, 4570BFC8DD6665A91381F400 /* AppLockSetupBiometricsScreen */, 570026F1BA71A2D167652E48 /* AppLockSetupPINScreen */, + 2AC6FD695C6F79F67C056463 /* AppLockSetupSettingsScreen */, FB039572AA54E0690B4051AD /* Common */, ); path = AppLock; @@ -2347,6 +2339,18 @@ path = ComposerToolbar; sourceTree = ""; }; + 2AC6FD695C6F79F67C056463 /* AppLockSetupSettingsScreen */ = { + isa = PBXGroup; + children = ( + 3D9FCE4D1E3A81AC1CC5CB91 /* AppLockSetupSettingsScreenCoordinator.swift */, + 502F986D57158674172C58E3 /* AppLockSetupSettingsScreenModels.swift */, + 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */, + 8610C1D21565C950BCA6A454 /* AppLockSetupSettingsScreenViewModelProtocol.swift */, + 5C3CC0EA3D5B1E74EC71A463 /* View */, + ); + path = AppLockSetupSettingsScreen; + sourceTree = ""; + }; 2C0F49BD446849654C0D24E0 /* RoomMember */ = { isa = PBXGroup; children = ( @@ -2928,6 +2932,7 @@ isa = PBXGroup; children = ( FCE93F0CBF0D96B77111C413 /* AppLockFlowCoordinator.swift */, + 0DBB08A95EFA668F2CF27211 /* AppLockSetupFlowCoordinator.swift */, 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */, C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */, E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */, @@ -2961,6 +2966,14 @@ path = SoftLogoutScreen; sourceTree = ""; }; + 5C3CC0EA3D5B1E74EC71A463 /* View */ = { + isa = PBXGroup; + children = ( + 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 5DC032EC5F19009AA7CADB41 /* View */ = { isa = PBXGroup; children = ( @@ -3662,7 +3675,6 @@ 16037EE9E9A52AF37B7818E3 /* AnalyticsSettingsScreenUITests.swift */, 7D0CBC76C80E04345E11F2DB /* Application.swift */, 349C633291427A0F29C28C54 /* AppLockScreenUITests.swift */, - 5F088B61525099A48909743B /* AppLockSettingsScreenUITests.swift */, AEDC38E389B89BCF5C1AFD4A /* AppLockSetupPINScreenUITests.swift */, 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */, C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */, @@ -4280,18 +4292,6 @@ path = Layout; sourceTree = ""; }; - CE39C9B97963CC30AB0859E5 /* AppLockSettingsScreen */ = { - isa = PBXGroup; - children = ( - 267C0279BB8D907E2C40DDCA /* AppLockSettingsScreenCoordinator.swift */, - B86F43BF1C26CB31B4BFA610 /* AppLockSettingsScreenModels.swift */, - 892EF45CCC5D2BF0FD1F770C /* AppLockSettingsScreenViewModel.swift */, - 33284693F54382F46CFD2EDD /* AppLockSettingsScreenViewModelProtocol.swift */, - 01FACE97F4C9E9F522D401EB /* View */, - ); - path = AppLockSettingsScreen; - sourceTree = ""; - }; D4DB8163C10389C069458252 /* RoomMemberListScreen */ = { isa = PBXGroup; children = ( @@ -5277,21 +5277,22 @@ E79D79CDAFE8BEBCC3AECA54 /* AppLockScreenViewModelProtocol.swift in Sources */, 1D623953F970D11F6F38499C /* AppLockService.swift in Sources */, D2048FD56760BDABA3DB5FC2 /* AppLockServiceProtocol.swift in Sources */, - 3627DFEE96824E0E2EA69B88 /* AppLockSettingsScreen.swift in Sources */, - 84215E902C62E9B8E8AB79F0 /* AppLockSettingsScreenCoordinator.swift in Sources */, - 0AD81E04A8C024C09B7AEAC5 /* AppLockSettingsScreenModels.swift in Sources */, - A7BEE8216B4B12BE4C0F2C3F /* AppLockSettingsScreenViewModel.swift in Sources */, - 0206016CCEF6EF9365916768 /* AppLockSettingsScreenViewModelProtocol.swift in Sources */, BB9B800C6094E34860E89DC5 /* AppLockSetupBiometricsScreen.swift in Sources */, 8015842CB4DE1BE414D2CDED /* AppLockSetupBiometricsScreenCoordinator.swift in Sources */, 21813AF91CFC6F3E3896DB53 /* AppLockSetupBiometricsScreenModels.swift in Sources */, 37906355E207DB5703754675 /* AppLockSetupBiometricsScreenViewModel.swift in Sources */, 9FB41B0E8B2AA9B404E52C8B /* AppLockSetupBiometricsScreenViewModelProtocol.swift in Sources */, + B188D0907A4D38AAAF6FEFA8 /* AppLockSetupFlowCoordinator.swift in Sources */, 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */, 9696ECAFB4F0C079C5C2A526 /* AppLockSetupPINScreenCoordinator.swift in Sources */, E4B07FF075C99D04D9AF792D /* AppLockSetupPINScreenModels.swift in Sources */, 0F6C8033FA60CFD36F7CA205 /* AppLockSetupPINScreenViewModel.swift in Sources */, 4C356F5CCB4CDC99BFA45185 /* AppLockSetupPINScreenViewModelProtocol.swift in Sources */, + 4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */, + 8DCD9CC5361FF22A5B2C20F1 /* AppLockSetupSettingsScreenCoordinator.swift in Sources */, + 6E4E401BE97AC241DA7C7716 /* AppLockSetupSettingsScreenModels.swift in Sources */, + 4807E8F51DB54F56B25E1C7E /* AppLockSetupSettingsScreenViewModel.swift in Sources */, + C85C7A201E4CFDA477ACEBEB /* AppLockSetupSettingsScreenViewModelProtocol.swift in Sources */, EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */, 355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */, 12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */, @@ -5908,7 +5909,6 @@ 8024BE37156FF0A95A7A3465 /* AnalyticsPromptUITests.swift in Sources */, BF675964C9159F718589C36A /* AnalyticsSettingsScreenUITests.swift in Sources */, F05516474DB42369FD976CEF /* AppLockScreenUITests.swift in Sources */, - 61C345258DD392477E79A3B5 /* AppLockSettingsScreenUITests.swift in Sources */, 000765812BABB81F5174C601 /* AppLockSetupPINScreenUITests.swift in Sources */, 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */, ACF094CF3BF02DBFA6DFDE60 /* AuthenticationCoordinatorUITests.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 4a21f456c..0b728350b 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -144,6 +144,7 @@ "common_server_url" = "Server URL"; "common_settings" = "Settings"; "common_shared_location" = "Shared location"; +"common_signing_out" = "Signing out"; "common_starting_chat" = "Starting chat…"; "common_sticker" = "Sticker"; "common_success" = "Success"; @@ -297,7 +298,6 @@ "screen_app_lock_setup_pin_mismatch_dialog_title" = "PINs don't match"; "screen_app_lock_signout_alert_message" = "You’ll need to re-login and create a new PIN to proceed"; "screen_app_lock_signout_alert_title" = "You are being signed out"; -"screen_app_lock_subtitle" = "You have 3 attempts to unlock"; "screen_bug_report_attach_screenshot" = "Attach screenshot"; "screen_bug_report_contact_me" = "You may contact me if you have any follow up questions."; "screen_bug_report_contact_me_title" = "Contact me"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index 96c31dbb3..ae1ffecff 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -162,6 +162,22 @@ %1$d room changes + screen_app_lock_subtitle + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + You have %1$d attempt to unlock + other + You have %1$d attempts to unlock + + screen_app_lock_subtitle_wrong_pin NSStringLocalizedFormatKey diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 4323cb9e9..6eb982a3f 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -102,8 +102,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, backgroundTaskService: backgroundTaskService) let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings) + let appLockNavigationCoordinator = NavigationRootCoordinator() + let appLockFlowUserIndicatorController = UserIndicatorController(rootCoordinator: appLockNavigationCoordinator) appLockFlowCoordinator = AppLockFlowCoordinator(appLockService: appLockService, - navigationCoordinator: NavigationRootCoordinator()) + userIndicatorController: appLockFlowUserIndicatorController, + navigationCoordinator: appLockNavigationCoordinator) notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(), appSettings: appSettings) @@ -329,12 +332,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, // We can ignore signOut when already in the process of signing out, // such as the SDK sending an authError due to token invalidation. break - case (_, .signOut(let isSoft), .signingOut): + case (_, .signOut(let isSoft, _), .signingOut): self.logout(isSoft: isSoft) - case (.signingOut, .completedSigningOut, .signedOut): - self.presentSplashScreen(isSoftLogout: false) - case (.signingOut, .showSoftLogout, .softLogout): - self.presentSplashScreen(isSoftLogout: true) + case (.signingOut(_, let disableAppLock), .completedSigningOut, .signedOut): + self.presentSplashScreen(isSoftLogout: false, disableAppLock: disableAppLock) + case (.signingOut(_, let disableAppLock), .showSoftLogout, .softLogout): + self.presentSplashScreen(isSoftLogout: true, disableAppLock: disableAppLock) case (.signedIn, .clearCache, .initial): self.clearCache() default: @@ -371,7 +374,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, navigationStackCoordinator: authenticationNavigationStackCoordinator, appSettings: appSettings, analytics: ServiceLocator.shared.analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appLockService: appLockFlowCoordinator.appLockService) authenticationCoordinator?.delegate = self authenticationCoordinator?.start() @@ -410,7 +414,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, stateMachine.processEvent(.createdUserSession) case .clearAllData: self.softLogoutCoordinator = nil - stateMachine.processEvent(.signOut(isSoft: false)) + stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false)) } } .store(in: &cancellables) @@ -439,7 +443,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, switch action { case .logout: - stateMachine.processEvent(.signOut(isSoft: false)) + stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false)) case .clearCache: stateMachine.processEvent(.clearCache) } @@ -512,7 +516,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, notificationManager.setUserSession(nil) } - private func presentSplashScreen(isSoftLogout: Bool = false) { + private func presentSplashScreen(isSoftLogout: Bool = false, disableAppLock: Bool = false) { navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator()) if isSoftLogout { @@ -520,6 +524,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, } else { startAuthentication() } + + if disableAppLock { + Task { + // Ensure the navigation stack has settled. + try? await Task.sleep(for: .milliseconds(500)) + appLockFlowCoordinator.appLockService.disable() + windowManager.switchToMain() + } + } } private func configureNotificationManager() { @@ -549,7 +562,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, guard let self else { return } switch callback { case .didReceiveAuthError(let isSoftLogout): - stateMachine.processEvent(.signOut(isSoft: isSoftLogout)) + stateMachine.processEvent(.signOut(isSoft: isSoftLogout, disableAppLock: false)) default: break } @@ -583,6 +596,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, windowManager.switchToAlternate() case .unlockApp: windowManager.switchToMain() + case .forceLogout: + stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Application/AppCoordinatorStateMachine.swift b/ElementX/Sources/Application/AppCoordinatorStateMachine.swift index 714cb6e04..ea05f9d33 100644 --- a/ElementX/Sources/Application/AppCoordinatorStateMachine.swift +++ b/ElementX/Sources/Application/AppCoordinatorStateMachine.swift @@ -33,7 +33,7 @@ class AppCoordinatorStateMachine { case signedIn /// Processing a sign out request - case signingOut(isSoft: Bool) + case signingOut(isSoft: Bool, disableAppLock: Bool) } /// Events that can be triggered on the AppCoordinator state machine @@ -51,7 +51,7 @@ class AppCoordinatorStateMachine { case createdUserSession /// Request sign out. - case signOut(isSoft: Bool) + case signOut(isSoft: Bool, disableAppLock: Bool) /// Request the soft logout screen. case showSoftLogout /// Signing out completed. @@ -80,16 +80,17 @@ class AppCoordinatorStateMachine { stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn]) stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) - stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false) => .signedOut]) - stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true) => .softLogout]) + stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false, disableAppLock: false) => .signedOut, + .signingOut(isSoft: false, disableAppLock: true) => .signedOut]) + stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true, disableAppLock: false) => .softLogout]) stateMachine.addRoutes(event: .clearCache, transitions: [.signedIn => .initial]) // Transitions with associated values need to be handled through `addRouteMapping` stateMachine.addRouteMapping { event, fromState, _ in switch (event, fromState) { - case (.signOut(let isSoft), _): - return .signingOut(isSoft: isSoft) + case (.signOut(let isSoft, let disableAppLock), _): + return .signingOut(isSoft: isSoft, disableAppLock: disableAppLock) default: return nil } diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 482399406..59a186008 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -22,6 +22,9 @@ final class AppSettings { private enum UserDefaultsKeys: String { case lastVersionLaunched case seenInvites + case hasShownWelcomeScreen + case appLockNumberOfPINAttempts + case appLockNumberOfBiometricAttempts case lastLoginDate case migratedAccounts case timelineStyle @@ -38,7 +41,6 @@ final class AppSettings { case shouldCollapseRoomStateEvents case userSuggestionsEnabled case readReceiptsEnabled - case hasShownWelcomeScreen case swiftUITimelineEnabled case voiceMessageEnabled case mentionsEnabled @@ -122,10 +124,18 @@ final class AppSettings { // MARK: - Security + /// The app must be locked with a PIN code as part of the authentication flow. + let appLockIsMandatory = false /// The amount of time the app can remain in the background for without requesting the PIN/TouchID/FaceID. let appLockGracePeriod: TimeInterval = 180 /// Any codes that the user isn't allowed to use for their PIN. let appLockPINCodeBlockList = ["0000", "1234"] + /// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked). + @UserPreference(key: UserDefaultsKeys.appLockNumberOfPINAttempts, defaultValue: 0, storageType: .userDefaults(store)) + var appLockNumberOfPINAttempts: Int + /// The number of attempts the user has made to unlock the app with Touch/Face ID (resets when unlocked). + @UserPreference(key: UserDefaultsKeys.appLockNumberOfBiometricAttempts, defaultValue: 0, storageType: .userDefaults(store)) + var appLockNumberOfBiometricAttempts: Int // MARK: - Authentication diff --git a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift index 6ff67b94d..2f84fd031 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift @@ -22,11 +22,14 @@ enum AppLockFlowCoordinatorAction: Equatable { case lockApp /// Hide the unlock flow. case unlockApp + /// Forces a logout of the user. + case forceLogout } /// Coordinates the display of any screens shown when the app is locked. class AppLockFlowCoordinator: CoordinatorProtocol { let appLockService: AppLockServiceProtocol + let userIndicatorController: UserIndicatorController let navigationCoordinator: NavigationRootCoordinator private var cancellables: Set = [] @@ -36,10 +39,18 @@ class AppLockFlowCoordinator: CoordinatorProtocol { actionsSubject.eraseToAnyPublisher() } - init(appLockService: AppLockServiceProtocol, navigationCoordinator: NavigationRootCoordinator) { + init(appLockService: AppLockServiceProtocol, userIndicatorController: UserIndicatorController, navigationCoordinator: NavigationRootCoordinator) { self.appLockService = appLockService + self.userIndicatorController = userIndicatorController self.navigationCoordinator = navigationCoordinator + appLockService.disabledPublisher + .sink { [weak self] _ in + // When the service is disabled via a force logout, we need to remove the activity indicator. + self?.userIndicatorController.retractAllIndicators() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) .sink { [weak self] _ in self?.applicationDidEnterBackground() @@ -54,7 +65,7 @@ class AppLockFlowCoordinator: CoordinatorProtocol { } func toPresentable() -> AnyView { - AnyView(navigationCoordinator.toPresentable()) + AnyView(userIndicatorController.toPresentable()) } // MARK: - App unlock @@ -91,6 +102,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol { switch action { case .appUnlocked: actionsSubject.send(.unlockApp) + case .forceLogout: + userIndicatorController.submitIndicator(UserIndicator(type: .modal, title: L10n.commonSigningOut, persistent: true)) + actionsSubject.send(.forceLogout) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift new file mode 100644 index 000000000..767f868a6 --- /dev/null +++ b/ElementX/Sources/FlowCoordinators/AppLockSetupFlowCoordinator.swift @@ -0,0 +1,260 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftState +import SwiftUI + +enum AppLockSetupFlowCoordinatorAction: Equatable { + case complete +} + +/// Coordinates the display of any screens used to configure the App Lock feature. +class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol { + private let presentingFlow: PresentationFlow + private let appLockService: AppLockServiceProtocol + private let navigationStackCoordinator: NavigationStackCoordinator + private let modalNavigationStackCoordinator = NavigationStackCoordinator() + + /// The presentation context of the flow. + enum PresentationFlow { + /// The flow is shown as for mandatory PIN creation in the authentication flow + case authentication + /// The flow is shown from the Settings screen. + case settings + } + + /// States the flow can find itself in + enum State: StateType { + /// The initial state, used before the flow starts + case initial + /// The unlock screen. + case unlock + /// The create PIN screen. + case createPIN + /// The allow biometrics screen. + case biometricsPrompt + /// The settings screen. + case settings + /// The flow is finished. + case complete + } + + /// Events that can be triggered on the flow state machine + enum Event: EventType { + /// Start the flow. + case start + /// The user entered a PIN. + case pinEntered + /// The user completed the biometrics prompt. + case biometricsSet + /// The user wants to change their PIN. + case changePIN + /// The user wants to dismiss the flow. + case dismiss + } + + private let stateMachine: StateMachine + private var cancellables: Set = [] + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(presentingFlow: PresentationFlow, appLockService: AppLockServiceProtocol, navigationStackCoordinator: NavigationStackCoordinator) { + self.presentingFlow = presentingFlow + self.appLockService = appLockService + self.navigationStackCoordinator = navigationStackCoordinator + + stateMachine = .init(state: .initial) + configureStateMachine() + } + + func start() { + stateMachine.tryEvent(.start) + } + + func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { + // Deep links not supported. + } + + func clearRoute(animated: Bool) { + // Deep links not supported. + } + + // MARK: - Private + + private func configureStateMachine() { + stateMachine.addRouteMapping { [weak self] event, fromState, _ in + guard let self else { return nil } + + switch (event, fromState) { + case (.start, .initial): + if presentingFlow == .authentication { return .createPIN } + return appLockService.isEnabled ? .unlock : .createPIN + case (.pinEntered, .unlock): + return .settings + case (.pinEntered, .createPIN): + if presentingFlow == .authentication { + return appLockService.biometryType != .none ? .biometricsPrompt : .complete + } else { + return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt + } + case (.biometricsSet, .biometricsPrompt): + return presentingFlow == .settings ? .settings : .complete + case (.changePIN, .settings): + return .createPIN + case (.dismiss, _): + return .complete + default: + return nil + } + } + + stateMachine.addAnyHandler(.any => .any) { [weak self] context in + guard let self else { return } + + MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(String(describing: context.event))`.") + switch (context.fromState, context.toState) { + case (.initial, .unlock): + showPINUnlock() + case (.initial, .createPIN): + showCreatePIN() + case (.unlock, .settings): + showSettings() + case (.createPIN, .biometricsPrompt): + showBiometricsPrompt() + case (.createPIN, .settings): + navigationStackCoordinator.setSheetCoordinator(nil) + case (.biometricsPrompt, .settings): + showSettings() + case (.settings, .createPIN): + showCreatePIN() + case (_, .complete): + complete(from: context.fromState) + default: + fatalError("Unhandled transition.") + } + } + + stateMachine.addErrorHandler { context in + fatalError("Unexpected transition from `\(context.fromState)` to `\(context.toState)` with event `\(String(describing: context.event))`.") + } + } + + private func showCreatePIN() { + // Despite appLockService.isMandatory existing, we don't use that here, + // to allow for cancellation when changing the PIN code within settings. + let isMandatory = presentingFlow == .authentication + + let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .create, + isMandatory: isMandatory, + appLockService: appLockService)) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .cancel: + stateMachine.tryEvent(.dismiss) + case .complete: + stateMachine.tryEvent(.pinEntered) + } + } + .store(in: &cancellables) + + if presentingFlow == .authentication { + navigationStackCoordinator.push(coordinator) + } else { + modalNavigationStackCoordinator.setRootCoordinator(coordinator) + navigationStackCoordinator.setSheetCoordinator(modalNavigationStackCoordinator) + } + } + + private func showBiometricsPrompt() { + let coordinator = AppLockSetupBiometricsScreenCoordinator(parameters: .init(appLockService: appLockService)) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .continue: + stateMachine.tryEvent(.biometricsSet) + } + } + .store(in: &cancellables) + + if presentingFlow == .authentication { + navigationStackCoordinator.push(coordinator) + } else { + modalNavigationStackCoordinator.push(coordinator) + } + } + + private func showPINUnlock() { + let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .unlock, + isMandatory: false, + appLockService: appLockService)) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .cancel: + stateMachine.tryEvent(.dismiss) + case .complete: + stateMachine.tryEvent(.pinEntered) + } + } + .store(in: &cancellables) + modalNavigationStackCoordinator.setRootCoordinator(coordinator) + navigationStackCoordinator.setSheetCoordinator(modalNavigationStackCoordinator) + } + + private func showSettings() { + let coordinator = AppLockSetupSettingsScreenCoordinator(parameters: .init(isMandatory: appLockService.isMandatory, + appLockService: appLockService)) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .changePINCode: + stateMachine.tryEvent(.changePIN) + case .appLockDisabled: + stateMachine.tryEvent(.dismiss) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in + self?.actionsSubject.send(.complete) + } + navigationStackCoordinator.setSheetCoordinator(nil) + } + + /// Tear down the flow for completion. + private func complete(from state: State) { + switch state { + case .initial, .complete: fatalError() + case .unlock: + navigationStackCoordinator.setSheetCoordinator(nil) + actionsSubject.send(.complete) + case .createPIN: + navigationStackCoordinator.setSheetCoordinator(nil) + actionsSubject.send(.complete) + case .biometricsPrompt: + navigationStackCoordinator.setSheetCoordinator(nil) + actionsSubject.send(.complete) + case .settings: + navigationStackCoordinator.pop() + actionsSubject.send(.complete) + } + } +} diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index c51099d3e..942868cbc 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -328,6 +328,8 @@ public enum L10n { public static var commonSettings: String { return L10n.tr("Localizable", "common_settings") } /// Shared location public static var commonSharedLocation: String { return L10n.tr("Localizable", "common_shared_location") } + /// Signing out + public static var commonSigningOut: String { return L10n.tr("Localizable", "common_signing_out") } /// Starting chat… public static var commonStartingChat: String { return L10n.tr("Localizable", "common_starting_chat") } /// Sticker @@ -724,8 +726,10 @@ public enum L10n { public static var screenAppLockSignoutAlertMessage: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_message") } /// You are being signed out public static var screenAppLockSignoutAlertTitle: String { return L10n.tr("Localizable", "screen_app_lock_signout_alert_title") } - /// You have 3 attempts to unlock - public static var screenAppLockSubtitle: String { return L10n.tr("Localizable", "screen_app_lock_subtitle") } + /// Plural format key: "%#@COUNT@" + public static func screenAppLockSubtitle(_ p1: Int) -> String { + return L10n.tr("Localizable", "screen_app_lock_subtitle", p1) + } /// Plural format key: "%#@COUNT@" public static func screenAppLockSubtitleWrongPin(_ p1: Int) -> String { return L10n.tr("Localizable", "screen_app_lock_subtitle_wrong_pin", p1) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 318767ce1..84a5f5acf 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -117,6 +117,11 @@ class AnalyticsClientMock: AnalyticsClientProtocol { } } class AppLockServiceMock: AppLockServiceProtocol { + var isMandatory: Bool { + get { return underlyingIsMandatory } + set(value) { underlyingIsMandatory = value } + } + var underlyingIsMandatory: Bool! var isEnabled: Bool { get { return underlyingIsEnabled } set(value) { underlyingIsEnabled = value } @@ -132,6 +137,21 @@ class AppLockServiceMock: AppLockServiceProtocol { set(value) { underlyingBiometricUnlockEnabled = value } } var underlyingBiometricUnlockEnabled: Bool! + var disabledPublisher: AnyPublisher { + get { return underlyingDisabledPublisher } + set(value) { underlyingDisabledPublisher = value } + } + var underlyingDisabledPublisher: AnyPublisher! + var numberOfPINAttempts: AnyPublisher { + get { return underlyingNumberOfPINAttempts } + set(value) { underlyingNumberOfPINAttempts = value } + } + var underlyingNumberOfPINAttempts: AnyPublisher! + var numberOfBiometricAttempts: AnyPublisher { + get { return underlyingNumberOfBiometricAttempts } + set(value) { underlyingNumberOfBiometricAttempts = value } + } + var underlyingNumberOfBiometricAttempts: AnyPublisher! //MARK: - setupPINCode diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenCoordinator.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenCoordinator.swift index 79db0d304..ea1ac1da8 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenCoordinator.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenCoordinator.swift @@ -25,6 +25,8 @@ struct AppLockScreenCoordinatorParameters { enum AppLockScreenCoordinatorAction { /// The user has successfully unlocked the app. case appUnlocked + /// The user failed to unlock the app (or forgot their PIN). + case forceLogout } final class AppLockScreenCoordinator: CoordinatorProtocol { @@ -51,6 +53,8 @@ final class AppLockScreenCoordinator: CoordinatorProtocol { switch action { case .appUnlocked: self.actionsSubject.send(.appUnlocked) + case .forceLogout: + self.actionsSubject.send(.forceLogout) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift index 63e34ab90..f73069e09 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenModels.swift @@ -19,6 +19,8 @@ import Foundation enum AppLockScreenViewModelAction { /// The user has successfully unlocked the app. case appUnlocked + /// The user failed to unlock the app (or forgot their PIN). + case forceLogout } struct AppLockScreenViewState: BindableState { @@ -36,7 +38,7 @@ struct AppLockScreenViewState: BindableState { /// The string shown in the screen's subtitle. var subtitle: String { if !isSubtitleWarning { - return L10n.screenAppLockSubtitle + return L10n.screenAppLockSubtitle(maximumAttempts) } else { return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfPINAttempts) } @@ -50,8 +52,8 @@ struct AppLockScreenViewStateBindings { } enum AppLockScreenAlertType { - /// The user has failed too many times, they're being signed out. - case forceSignOut + /// The user has failed too many times, they're being logged out. + case forcedLogout /// The user has forgotten their PIN, confirm they're happy to sign out. case confirmResetPIN } diff --git a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift index 001c7e1a7..7d78959dd 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockScreen/AppLockScreenViewModel.swift @@ -31,6 +31,12 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel self.appLockService = appLockService super.init(initialViewState: AppLockScreenViewState(bindings: .init())) + + appLockService.numberOfPINAttempts + .weakAssign(to: \.state.numberOfPINAttempts, on: self) + .store(in: &cancellables) + + showForceLogoutAlertIfNeeded() } // MARK: - Public @@ -58,24 +64,21 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel state.bindings.alertInfo = .init(id: .confirmResetPIN, title: L10n.screenAppLockSignoutAlertTitle, message: L10n.screenAppLockSignoutAlertMessage, - primaryButton: .init(title: L10n.actionOk, action: forceSignOut), + primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) }, secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) } private func handleInvalidPIN() { MXLog.warning("Invalid PIN code entered.") - state.numberOfPINAttempts += 1 - - if state.numberOfPINAttempts == 3 { - state.bindings.alertInfo = .init(id: .forceSignOut, - title: L10n.screenAppLockSignoutAlertTitle, - message: L10n.screenAppLockSignoutAlertMessage, - primaryButton: .init(title: L10n.actionOk, action: nil)) - forceSignOut() - } + showForceLogoutAlertIfNeeded() } - private func forceSignOut() { - // To be implemented. + private func showForceLogoutAlertIfNeeded() { + if state.numberOfPINAttempts >= 3 { + state.bindings.alertInfo = .init(id: .forcedLogout, + title: L10n.screenAppLockSignoutAlertTitle, + message: L10n.screenAppLockSignoutAlertMessage, + primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) }) + } } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/View/AppLockSetupBiometricsScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/View/AppLockSetupBiometricsScreen.swift index e311d0f71..833f62e1e 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/View/AppLockSetupBiometricsScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupBiometricsScreen/View/AppLockSetupBiometricsScreen.swift @@ -31,8 +31,10 @@ struct AppLockSetupBiometricsScreen: View { .padding(.top, UIConstants.iconTopPaddingToNavigationBar) .frame(maxWidth: .infinity) } - .toolbar(.visible, for: .navigationBar) .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .toolbar(.visible, for: .navigationBar) + .navigationBarBackButtonHidden() + .interactiveDismissDisabled() .safeAreaInset(edge: .bottom) { buttons .padding(.top, 16) diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift index a3790f482..0a500f3ed 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenCoordinator.swift @@ -21,6 +21,8 @@ struct AppLockSetupPINScreenCoordinatorParameters { /// Whether the screen should start in create or unlock mode. /// Specifying confirm here will raise a fatal error. let initialMode: AppLockSetupPINScreenMode + /// Whether the screen is mandatory or can be cancelled by the user. + let isMandatory: Bool let appLockService: AppLockServiceProtocol } @@ -46,6 +48,7 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol { self.parameters = parameters viewModel = AppLockSetupPINScreenViewModel(initialMode: parameters.initialMode, + isMandatory: parameters.isMandatory, appLockService: parameters.appLockService) } @@ -56,9 +59,9 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { case .complete: - break + actionsSubject.send(.complete) case .cancel: - break + actionsSubject.send(.cancel) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift index e695476a0..c41e75ae5 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenModels.swift @@ -35,6 +35,8 @@ enum AppLockSetupPINScreenMode { struct AppLockSetupPINScreenViewState: BindableState { /// The current mode that the screen is in. var mode: AppLockSetupPINScreenMode + /// Whether the screen is mandatory or can be cancelled. + let isMandatory: Bool var title: String { switch mode { diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift index faf5a0cdc..226724e03 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/AppLockSetupPINScreenViewModel.swift @@ -30,9 +30,9 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc actionsSubject.eraseToAnyPublisher() } - init(initialMode: AppLockSetupPINScreenMode, appLockService: AppLockServiceProtocol) { + init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) { self.appLockService = appLockService - super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, bindings: .init(pinCode: ""))) + super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: ""))) } // MARK: - Public diff --git a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift index 6d817c169..7d97f2478 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupPINScreen/View/AppLockSetupPINScreen.swift @@ -21,6 +21,8 @@ import SwiftUI struct AppLockSetupPINScreen: View { @ObservedObject var context: AppLockSetupPINScreenViewModel.Context + @FocusState private var textFieldFocus + var body: some View { ScrollView { VStack(spacing: 48) { @@ -28,6 +30,7 @@ struct AppLockSetupPINScreen: View { PINTextField(pinCode: $context.pinCode, isSecure: context.viewState.mode == .unlock) + .focused($textFieldFocus) .onChange(of: context.pinCode) { newValue in guard newValue.count == 4 else { return } context.send(viewAction: .submitPINCode) @@ -38,6 +41,12 @@ struct AppLockSetupPINScreen: View { .frame(maxWidth: .infinity) } .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .toolbar { toolbar } + .toolbar(.visible, for: .navigationBar) + .navigationBarBackButtonHidden() + .interactiveDismissDisabled(context.viewState.isMandatory) + .alert(item: $context.alertInfo) + .onAppear { textFieldFocus = true } } var header: some View { @@ -59,6 +68,17 @@ struct AppLockSetupPINScreen: View { } } } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + if !context.viewState.isMandatory { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.actionCancel) { + context.send(viewAction: .cancel) + } + } + } + } } // MARK: - Previews @@ -66,9 +86,15 @@ struct AppLockSetupPINScreen: View { struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview { static let service = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings) - static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: service) - static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm, appLockService: service) - static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, appLockService: service) + static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create, + isMandatory: false, + appLockService: service) + static let confirmViewModel = AppLockSetupPINScreenViewModel(initialMode: .confirm, + isMandatory: false, + appLockService: service) + static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, + isMandatory: false, + appLockService: service) static var previews: some View { NavigationStack { diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenCoordinator.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenCoordinator.swift similarity index 53% rename from ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenCoordinator.swift rename to ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenCoordinator.swift index 254ce2b55..5e1fc57b3 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenCoordinator.swift @@ -17,28 +17,31 @@ import Combine import SwiftUI -struct AppLockSettingsScreenCoordinatorParameters { +struct AppLockSetupSettingsScreenCoordinatorParameters { + /// Whether App Lock is mandatory and can be disabled by the user. + let isMandatory: Bool let appLockService: AppLockServiceProtocol } -enum AppLockSettingsScreenCoordinatorAction { - case done +enum AppLockSetupSettingsScreenCoordinatorAction { + case changePINCode + case appLockDisabled } -final class AppLockSettingsScreenCoordinator: CoordinatorProtocol { - private let parameters: AppLockSettingsScreenCoordinatorParameters - private var viewModel: AppLockSettingsScreenViewModelProtocol - private let actionsSubject: PassthroughSubject = .init() +final class AppLockSetupSettingsScreenCoordinator: CoordinatorProtocol { + private let parameters: AppLockSetupSettingsScreenCoordinatorParameters + private var viewModel: AppLockSetupSettingsScreenViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() private var cancellables = Set() - var actions: AnyPublisher { + var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(parameters: AppLockSettingsScreenCoordinatorParameters) { + init(parameters: AppLockSetupSettingsScreenCoordinatorParameters) { self.parameters = parameters - viewModel = AppLockSettingsScreenViewModel(appLockService: parameters.appLockService) + viewModel = AppLockSetupSettingsScreenViewModel(appLockService: parameters.appLockService) } func start() { @@ -48,15 +51,15 @@ final class AppLockSettingsScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { case .changePINCode: - break - case .done: - self.actionsSubject.send(.done) + actionsSubject.send(.changePINCode) + case .appLockDisabled: + actionsSubject.send(.appLockDisabled) } } .store(in: &cancellables) } func toPresentable() -> AnyView { - AnyView(AppLockSettingsScreen(context: viewModel.context)) + AnyView(AppLockSetupSettingsScreen(context: viewModel.context)) } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift similarity index 70% rename from ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift rename to ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift index 504930494..b83cf036b 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenModels.swift @@ -17,36 +17,38 @@ import Foundation import LocalAuthentication -enum AppLockSettingsScreenViewModelAction { +enum AppLockSetupSettingsScreenViewModelAction { /// The user would like to enter new PIN code. case changePINCode - case done + /// The user has disabled the App Lock feature. + case appLockDisabled } -struct AppLockSettingsScreenViewState: BindableState { +struct AppLockSetupSettingsScreenViewState: BindableState { + /// Whether App Lock is mandatory and can be disabled by the user. + let isMandatory: Bool let biometryType: LABiometryType - var bindings: AppLockSettingsScreenViewStateBindings + var bindings: AppLockSetupSettingsScreenViewStateBindings var supportsBiometry: Bool { biometryType != .none } var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) } } -struct AppLockSettingsScreenViewStateBindings { +struct AppLockSetupSettingsScreenViewStateBindings { var enableBiometrics: Bool - var alertInfo: AlertInfo? + var alertInfo: AlertInfo? } -enum AppLockSettingsScreenAlertType { +enum AppLockSetupSettingsScreenAlertType { /// The alert shown to confirm the user would like to remove their PIN. case confirmRemovePINCode } -enum AppLockSettingsScreenViewAction { +enum AppLockSetupSettingsScreenViewAction { /// The user would like to enter a new PIN code. case changePINCode /// The user would like to disable the App Lock feature. case disable /// The user has toggled the biometrics setting. case enableBiometricsChanged - case done } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift similarity index 70% rename from ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift rename to ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift index 0c5c854e7..30fe7c7c1 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModel.swift @@ -17,25 +17,26 @@ import Combine import SwiftUI -typealias AppLockSettingsScreenViewModelType = StateStoreViewModel +typealias AppLockSetupSettingsScreenViewModelType = StateStoreViewModel -class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLockSettingsScreenViewModelProtocol { +class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelType, AppLockSetupSettingsScreenViewModelProtocol { private let appLockService: AppLockServiceProtocol - private var actionsSubject: PassthroughSubject = .init() + private var actionsSubject: PassthroughSubject = .init() - var actions: AnyPublisher { + var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } init(appLockService: AppLockServiceProtocol) { self.appLockService = appLockService - super.init(initialViewState: AppLockSettingsScreenViewState(biometryType: appLockService.biometryType, - bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled))) + super.init(initialViewState: AppLockSetupSettingsScreenViewState(isMandatory: appLockService.isMandatory, + biometryType: appLockService.biometryType, + bindings: .init(enableBiometrics: appLockService.biometricUnlockEnabled))) } // MARK: - Public - override func process(viewAction: AppLockSettingsScreenViewAction) { + override func process(viewAction: AppLockSetupSettingsScreenViewAction) { MXLog.info("View model: received view action: \(viewAction)") switch viewAction { @@ -45,8 +46,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc showRemovePINAlert() case .enableBiometricsChanged: appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics - case .done: - actionsSubject.send(.done) } } @@ -64,6 +63,6 @@ class AppLockSettingsScreenViewModel: AppLockSettingsScreenViewModelType, AppLoc /// Removes the user's PIN code, disabling the App Lock feature. private func completeRemovePIN() { appLockService.disable() - actionsSubject.send(.done) + actionsSubject.send(.appLockDisabled) } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModelProtocol.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModelProtocol.swift similarity index 74% rename from ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModelProtocol.swift rename to ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModelProtocol.swift index 8fd9e1b6f..12e554df9 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/AppLockSettingsScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/AppLockSetupSettingsScreenViewModelProtocol.swift @@ -17,7 +17,7 @@ import Combine @MainActor -protocol AppLockSettingsScreenViewModelProtocol { - var actions: AnyPublisher { get } - var context: AppLockSettingsScreenViewModelType.Context { get } +protocol AppLockSetupSettingsScreenViewModelProtocol { + var actions: AnyPublisher { get } + var context: AppLockSetupSettingsScreenViewModelType.Context { get } } diff --git a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift similarity index 58% rename from ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift rename to ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift index f5e4c2e64..a69f22ec4 100644 --- a/ElementX/Sources/Screens/AppLock/AppLockSettingsScreen/View/AppLockSettingsScreen.swift +++ b/ElementX/Sources/Screens/AppLock/AppLockSetupSettingsScreen/View/AppLockSetupSettingsScreen.swift @@ -17,16 +17,19 @@ import Compound import SwiftUI -struct AppLockSettingsScreen: View { - @ObservedObject var context: AppLockSettingsScreenViewModel.Context +struct AppLockSetupSettingsScreen: View { + @ObservedObject var context: AppLockSetupSettingsScreenViewModel.Context var body: some View { Form { Section { ListRow(label: .plain(title: L10n.screenAppLockSettingsChangePin), kind: .button { context.send(viewAction: .changePINCode) }) - ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive), - kind: .button { context.send(viewAction: .disable) }) + + if !context.viewState.isMandatory { + ListRow(label: .plain(title: L10n.screenAppLockSettingsRemovePin, role: .destructive), + kind: .button { context.send(viewAction: .disable) }) + } } if context.viewState.supportsBiometry { @@ -48,25 +51,25 @@ struct AppLockSettingsScreen: View { // MARK: - Previews -struct AppLockSettingsScreen_Previews: PreviewProvider, TestablePreview { - static let faceIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID)) - static let touchIDViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .touchID)) - static let biometricsUnavailableViewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none)) +struct AppLockSetupSettingsScreen_Previews: PreviewProvider, TestablePreview { + static let faceIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .faceID)) + static let touchIDViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(isMandatory: true, biometryType: .touchID)) + static let biometricsUnavailableViewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock(biometryType: .none)) static var previews: some View { NavigationStack { - AppLockSettingsScreen(context: faceIDViewModel.context) + AppLockSetupSettingsScreen(context: faceIDViewModel.context) } .previewDisplayName("Face ID") NavigationStack { - AppLockSettingsScreen(context: touchIDViewModel.context) + AppLockSetupSettingsScreen(context: touchIDViewModel.context) } - .previewDisplayName("Touch ID") + .previewDisplayName("Touch ID (Mandatory)") NavigationStack { - AppLockSettingsScreen(context: biometricsUnavailableViewModel.context) + AppLockSetupSettingsScreen(context: biometricsUnavailableViewModel.context) } - .previewDisplayName("No Biometrics") + .previewDisplayName("PIN only") } } diff --git a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift index 16c800f0c..ab100a3a0 100644 --- a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift @@ -29,10 +29,12 @@ class AuthenticationCoordinator: CoordinatorProtocol { private let appSettings: AppSettings private let analytics: AnalyticsService private let userIndicatorController: UserIndicatorControllerProtocol + private let appLockService: AppLockServiceProtocol private var cancellables = Set() private var oidcPresenter: OIDCAuthenticationPresenter? + private var appLockFlowCoordinator: AppLockSetupFlowCoordinator? weak var delegate: AuthenticationCoordinatorDelegate? @@ -40,12 +42,14 @@ class AuthenticationCoordinator: CoordinatorProtocol { navigationStackCoordinator: NavigationStackCoordinator, appSettings: AppSettings, analytics: AnalyticsService, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + appLockService: AppLockServiceProtocol) { self.authenticationService = authenticationService self.navigationStackCoordinator = navigationStackCoordinator self.appSettings = appSettings self.analytics = analytics self.userIndicatorController = userIndicatorController + self.appLockService = appLockService } func start() { @@ -64,7 +68,7 @@ class AuthenticationCoordinator: CoordinatorProtocol { oidcPresenter.handleUniversalLinkCallback(url) } - + // MARK: - Private private func showOnboarding() { @@ -237,25 +241,46 @@ class AuthenticationCoordinator: CoordinatorProtocol { private func userHasSignedIn(userSession: UserSessionProtocol) { appSettings.lastLoginDate = .now - showAnalyticsPromptIfNeeded { [weak self] in - guard let self else { return } - self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) + if appSettings.appLockIsMandatory, !appLockService.isEnabled { + showAppLockSetupFlow(userSession: userSession) + } else if analytics.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(userSession: userSession) + } else { + delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) } } - - private func showAnalyticsPromptIfNeeded(completion: @escaping () -> Void) { - guard analytics.shouldShowAnalyticsPrompt else { - completion() - return + + private func showAppLockSetupFlow(userSession: UserSessionProtocol) { + let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .authentication, + appLockService: appLockService, + navigationStackCoordinator: navigationStackCoordinator) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .complete: + appLockFlowCoordinator = nil + if analytics.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(userSession: userSession) + } else { + delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) + } + } } + .store(in: &cancellables) + appLockFlowCoordinator = coordinator + coordinator.start() + } + + private func showAnalyticsPrompt(userSession: UserSessionProtocol) { let coordinator = AnalyticsPromptScreenCoordinator(analytics: analytics, termsURL: appSettings.analyticsConfiguration.termsURL) coordinator.actions - .sink { action in + .sink { [weak self] action in + guard let self else { return } switch action { case .done: - completion() + delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index aadb9f309..0f25a70c7 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -39,6 +39,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { private let parameters: SettingsScreenCoordinatorParameters private var viewModel: SettingsScreenViewModelProtocol + private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator? + private let actionsSubject: PassthroughSubject = .init() private var cancellables = Set() @@ -68,7 +70,7 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { case .analytics: presentAnalyticsScreen() case .appLock: - presentAppLockSettingsScreen() + presentAppLockSetupFlow() case .reportBug: presentBugReportScreen() case .about: @@ -146,9 +148,27 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { parameters.navigationStackCoordinator?.push(coordinator) } - private func presentAppLockSettingsScreen() { - let coordinator = AppLockSettingsScreenCoordinator(parameters: .init(appLockService: parameters.appLockService)) - parameters.navigationStackCoordinator?.push(coordinator) + private func presentAppLockSetupFlow() { + guard let navigationStackCoordinator = parameters.navigationStackCoordinator else { + MXLog.error("The navigation stack has gone! 🌫️") + return + } + + let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .settings, + appLockService: parameters.appLockService, + navigationStackCoordinator: navigationStackCoordinator) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .complete: + // The flow coordinator tidies up the stack, no need to do anything. + appLockSetupFlowCoordinator = nil + } + } + .store(in: &cancellables) + + appLockSetupFlowCoordinator = coordinator + coordinator.start() } private func presentBugReportScreen() { diff --git a/ElementX/Sources/Services/AppLock/AppLockService.swift b/ElementX/Sources/Services/AppLock/AppLockService.swift index 160527894..12792cda5 100644 --- a/ElementX/Sources/Services/AppLock/AppLockService.swift +++ b/ElementX/Sources/Services/AppLock/AppLockService.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import LocalAuthentication /// The service responsible for locking and unlocking the app. @@ -24,6 +25,8 @@ class AppLockService: AppLockServiceProtocol { private let timer: AppLockTimer + var isMandatory: Bool { appSettings.appLockIsMandatory } + var isEnabled: Bool { do { guard appSettings.appLockFlowEnabled else { return false } @@ -38,6 +41,12 @@ class AppLockService: AppLockServiceProtocol { var biometryType: LABiometryType { context.biometryType } var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet. + var numberOfPINAttempts: AnyPublisher { appSettings.$appLockNumberOfPINAttempts } + var numberOfBiometricAttempts: AnyPublisher { appSettings.$appLockNumberOfBiometricAttempts } + + private var disabledSubject: PassthroughSubject = .init() + var disabledPublisher: AnyPublisher { disabledSubject.eraseToAnyPublisher() } + init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) { self.keychainController = keychainController self.appSettings = appSettings @@ -68,6 +77,9 @@ class AppLockService: AppLockServiceProtocol { func disable() { biometricUnlockEnabled = false keychainController.removePINCode() + appSettings.appLockNumberOfPINAttempts = 0 + appSettings.appLockNumberOfBiometricAttempts = 0 + disabledSubject.send() } func applicationDidEnterBackground() { @@ -79,12 +91,20 @@ class AppLockService: AppLockServiceProtocol { } func unlock(with pinCode: String) -> Bool { - guard pinCode == keychainController.pinCode() else { return false } + guard pinCode == keychainController.pinCode() else { + MXLog.warning("Wrong PIN entered.") + appSettings.appLockNumberOfPINAttempts += 1 + return false + } return completeUnlock() } func unlockWithBiometrics() -> Bool { - guard biometryType != .none, biometricUnlockEnabled else { return false } + guard biometryType != .none, biometricUnlockEnabled else { + MXLog.warning("\(biometryType) failed.") + appSettings.appLockNumberOfBiometricAttempts += 1 + return false + } return completeUnlock() } @@ -103,6 +123,8 @@ class AppLockService: AppLockServiceProtocol { /// Shared logic for completing an unlock via a PIN or biometry. private func completeUnlock() -> Bool { timer.registerUnlock() + appSettings.appLockNumberOfPINAttempts = 0 + appSettings.appLockNumberOfBiometricAttempts = 0 return true } } diff --git a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift index a31d455c4..c58343298 100644 --- a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift +++ b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import LocalAuthentication enum AppLockServiceError: Error { @@ -27,13 +28,19 @@ enum AppLockServiceError: Error { @MainActor protocol AppLockServiceProtocol: AnyObject { + /// The use of a PIN code is mandatory for this device. + var isMandatory: Bool { get } /// The app has been configured to automatically lock with a PIN code. var isEnabled: Bool { get } + /// The type of biometric authentication supported by the device. var biometryType: LABiometryType { get } /// Whether or not the user has enabled unlock via TouchID, FaceID or (possibly) OpticID. var biometricUnlockEnabled: Bool { get set } + /// A publisher that advertises when the service has been disabled. + var disabledPublisher: AnyPublisher { get } + /// Sets the user's PIN code used to unlock the app. func setupPINCode(_ pinCode: String) -> Result /// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice. @@ -50,15 +57,23 @@ protocol AppLockServiceProtocol: AnyObject { func unlock(with pinCode: String) -> Bool /// Attempt to unlock the app using FaceID or TouchID. func unlockWithBiometrics() -> Bool + + /// The number of attempts the user had made to unlock with a PIN code. + var numberOfPINAttempts: AnyPublisher { get } + /// The number of attempts the user has made to unlock with Touch/Face ID. + var numberOfBiometricAttempts: AnyPublisher { get } } // sourcery: AutoMockable extension AppLockServiceProtocol { } extension AppLockServiceMock { - static func mock(pinCode: String? = "2023", biometryType: LABiometryType = .faceID) -> AppLockServiceMock { + static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID) -> AppLockServiceMock { let mock = AppLockServiceMock() mock.isEnabled = pinCode != nil + mock.isMandatory = isMandatory + mock.numberOfPINAttempts = PassthroughSubject().eraseToAnyPublisher() + mock.numberOfBiometricAttempts = PassthroughSubject().eraseToAnyPublisher() mock.underlyingBiometryType = biometryType mock.underlyingBiometricUnlockEnabled = biometryType != .none mock.unlockWithClosure = { $0 == pinCode } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 3e1efbc8a..bcc42045c 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -127,7 +127,8 @@ class MockScreen: Identifiable { navigationStackCoordinator: navigationStackCoordinator, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appLockService: AppLockServiceMock()) retainedState.append(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -162,17 +163,12 @@ class MockScreen: Identifiable { let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService)) return coordinator case .appLockSetupFlow: - // Use the flow coordinator once more screens are added and remove the settings screen below. let navigationStackCoordinator = NavigationStackCoordinator() let appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings) - let coordinator = AppLockSetupPINScreenCoordinator(parameters: .init(initialMode: .create, appLockService: appLockService)) - navigationStackCoordinator.setRootCoordinator(coordinator) - return navigationStackCoordinator - case .appLockSettingsScreen: - let navigationStackCoordinator = NavigationStackCoordinator() - let appLockService = AppLockServiceMock.mock(biometryType: .faceID) - let coordinator = AppLockSettingsScreenCoordinator(parameters: .init(appLockService: appLockService)) - navigationStackCoordinator.setRootCoordinator(coordinator) + let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .settings, + appLockService: appLockService, + navigationStackCoordinator: navigationStackCoordinator) + coordinator.start() return navigationStackCoordinator case .home: let navigationStackCoordinator = NavigationStackCoordinator() diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 642ef051f..ea8358aed 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -31,7 +31,6 @@ enum UITestsScreenIdentifier: String { case templateScreen case appLockScreen case appLockSetupFlow - case appLockSettingsScreen case home case settings case bugReport diff --git a/UITests/Sources/AppLockSettingsScreenUITests.swift b/UITests/Sources/AppLockSettingsScreenUITests.swift deleted file mode 100644 index 73ae05b11..000000000 --- a/UITests/Sources/AppLockSettingsScreenUITests.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import ElementX -import XCTest - -@MainActor -class AppLockSettingsScreenUITests: XCTestCase { - func testScreen() async throws { - let app = Application.launch(.appLockSettingsScreen) - try await app.assertScreenshot(.appLockSettingsScreen) - } -} diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockScreen.png index c18e95c20..cb0478cb0 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockScreen.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e09c1a718424e75c120fc2f2038f786388fcb2542231bc737c332e0b23004b33 -size 67281 +oid sha256:02d0e19e329769fd507b7e02cbb95c55f36db403ba221b1362fea51e8e37a026 +size 100756 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSettingsScreen.png deleted file mode 100644 index 7b987cc58..000000000 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSettingsScreen.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3ebc5ae6771cf79d67f658af15893c271ac8181a4cc893ccac2c9ed96ff29d62 -size 72971 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSetupFlow-0.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSetupFlow-0.png index ef1e9ce26..c27ea0ccd 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSetupFlow-0.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.appLockSetupFlow-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4af88936877c1eb32530fe4eaaf0ec228f055a3c080766a15b81473752cdd53 -size 84142 +oid sha256:bf66d4809566af646252cf16b7582ef3bbb56b55308041d264902de44ea91463 +size 164655 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockScreen.png index 545c84d88..d57555bfd 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockScreen.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abb591b36f490253c34fd00092e45ba35961ba0aab4d6fcb8efb036ac3c2575e -size 67921 +oid sha256:326cb365efab4ca083b39104402e1056ae915e4a230dacd07f494343e427cef7 +size 117772 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSettingsScreen.png deleted file mode 100644 index f137cc41f..000000000 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSettingsScreen.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:908a80ad0ec73c875b8d581202903865d257ec23582fa3f3674c528569735aec -size 81618 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSetupFlow-0.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSetupFlow-0.png index fddcf89c9..1060ce326 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSetupFlow-0.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.appLockSetupFlow-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47ed1ba1d4a0f067dc463b8e122c81e910b57ce580c6d66b5aa66b9ec5239452 -size 91739 +oid sha256:1b0e2fe4e868bce1a4c758c8cc7026c17604bfe666c284ef83859ad0ac0aa001 +size 138452 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockScreen.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockScreen.png index c70a60904..cdc4d207e 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockScreen.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89df727aac25c1d26ec86fe564ae97ca1687ba9d710dd5fc234f0052676c0a19 -size 69836 +oid sha256:c5b462d8c9213706f06d4dab74a2b51bd29a5b961748ad3fd8156e48d407d832 +size 108960 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSettingsScreen.png deleted file mode 100644 index 012221c52..000000000 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSettingsScreen.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8eb2d44feaca1201363407063fcfd5356ed200030670533f43d4568da1d121b0 -size 76181 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSetupFlow-0.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSetupFlow-0.png index 3f8900c89..70e559ac1 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSetupFlow-0.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.appLockSetupFlow-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb43f034b7fb94655b025e4ae9e6b652c27ebb6b4ab04fdae61c5958ab3d16c2 -size 101999 +oid sha256:fee0ecd3529882e1799122602775c15cc0eb1f766885998b35cf078a06396640 +size 184338 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockScreen.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockScreen.png index cfffe9537..479aaeeb3 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockScreen.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d08f08f1d3e22048432ec4733cd0192e763325760d338864e9395cb24e124f21 -size 72314 +oid sha256:2d439b5c3e450115d6e46ae632ab3486a54763478db5e2111a763b9ab71da0cb +size 127567 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSettingsScreen.png deleted file mode 100644 index fa512eca0..000000000 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSettingsScreen.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:61fdc7d6975047d7c10592b8771484066e96705f28be0e1ec74ca7025e387fe7 -size 83815 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSetupFlow-0.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSetupFlow-0.png index 2901e02c5..1a76e6cf3 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSetupFlow-0.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.appLockSetupFlow-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d507ae7b47328770e3a82a730f00d9735a8d37da3df91818bcc1d76542b59325 -size 123595 +oid sha256:01d584c4c83f2abded3b6d929cf4ef3781c18c1ee2a1d6d88174458a1b117e12 +size 168409 diff --git a/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift index db146a4a6..da35d3157 100644 --- a/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift @@ -20,6 +20,7 @@ import XCTest @MainActor class AppLockScreenViewModelTests: XCTestCase { + var appSettings: AppSettings! var appLockService: AppLockService! var keychainController: KeychainControllerMock! var viewModel: AppLockScreenViewModelProtocol! @@ -28,8 +29,9 @@ class AppLockScreenViewModelTests: XCTestCase { override func setUp() { AppSettings.reset() + appSettings = AppSettings() keychainController = KeychainControllerMock() - appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings()) + appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings) viewModel = AppLockScreenViewModel(appLockService: appLockService) } @@ -51,4 +53,60 @@ class AppLockScreenViewModelTests: XCTestCase { // The app should become unlocked. XCTAssertEqual(result, .appUnlocked) } + + func testForgotPIN() { + // Given a fresh launch of the app. + XCTAssertNil(context.alertInfo, "No alert should be shown initially.") + + // When the user has forgotten their PIN. + context.send(viewAction: .forgotPIN) + + // Then an alert should be shown before logging out. + XCTAssertEqual(context.alertInfo?.id, .confirmResetPIN, "An alert should be shown before logging out.") + } + + func testUnlockFailure() async throws { + // Given an invalid PIN code. + let pinCode = "2024" + keychainController.pinCodeReturnValue = "2023" + XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.") + XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.") + XCTAssertNil(context.alertInfo, "No alert should be shown yet.") + + // When entering it on the lock screen. + viewModel.context.pinCode = pinCode + context.send(viewAction: .submitPINCode) + + // Then a failed attempt should be shown. + XCTAssertEqual(context.viewState.numberOfPINAttempts, 1, "A failed attempt should have been recorded.") + XCTAssertTrue(context.viewState.isSubtitleWarning, "A warning should now be shown.") + XCTAssertNil(context.alertInfo, "No alert should be shown yet.") + + // When entering twice more + context.send(viewAction: .submitPINCode) + context.send(viewAction: .submitPINCode) + + // Then an alert should be shown + XCTAssertEqual(context.viewState.numberOfPINAttempts, 3, "All the attempts should have been recorded.") + XCTAssertTrue(context.viewState.isSubtitleWarning, "The warning should still be shown.") + XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "An alert should now be shown.") + } + + func testForceQuitRequiresLogout() { + // Given an app with a PIN set where the user attempted to unlock 3 times. + keychainController.pinCodeReturnValue = "2023" + appSettings.appLockNumberOfPINAttempts = 2 + XCTAssertNil(context.alertInfo) + viewModel.context.pinCode = "0000" + context.send(viewAction: .submitPINCode) + XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 3, "The app should have 3 failed attempts before the force quit.") + XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "The app should be showing the alert before the force quit.") + + // When force quitting the app and relaunching. + viewModel = nil + let freshViewModel = AppLockScreenViewModel(appLockService: appLockService) + + // Then the alert should remain in place + XCTAssertEqual(freshViewModel.context.alertInfo?.id, .forcedLogout, "The new view model from the fresh launch should also show the alert") + } } diff --git a/UnitTests/Sources/AppLock/AppLockServiceTests.swift b/UnitTests/Sources/AppLock/AppLockServiceTests.swift index 6c3ffacee..b69799671 100644 --- a/UnitTests/Sources/AppLock/AppLockServiceTests.swift +++ b/UnitTests/Sources/AppLock/AppLockServiceTests.swift @@ -175,4 +175,47 @@ class AppLockServiceTests: XCTestCase { XCTAssertFalse(service.isEnabled, "The service should no longer be enabled.") XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.") } + + func testResetAttemptsOnUnlock() { + // Given a service that is enabled and has failed unlock attempts. + let pinCode = "2023" + guard case .success = service.setupPINCode(pinCode) else { + XCTFail("The PIN should be valid.") + return + } + appSettings.appLockNumberOfPINAttempts = 2 + appSettings.appLockNumberOfBiometricAttempts = 2 + XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.") + XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.") + XCTAssertTrue(service.isEnabled, "The service should be enabled.") + + // When unlocking the service + XCTAssertTrue(service.unlock(with: pinCode), "The PIN should work.") + + // Then the attempts counts should both be reset. + XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.") + XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.") + } + + func testResetAttemptsOnDisable() { + // Given a service that is enabled and has failed unlock attempts. + let pinCode = "2023" + guard case .success = service.setupPINCode(pinCode) else { + XCTFail("The PIN should be valid.") + return + } + appSettings.appLockNumberOfPINAttempts = 2 + appSettings.appLockNumberOfBiometricAttempts = 2 + XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.") + XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.") + XCTAssertTrue(service.isEnabled, "The service should be enabled.") + + // When disabling the service + service.disable() + XCTAssertFalse(service.isEnabled, "The service should be disabled.") + + // Then the attempts counts should both be reset. + XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.") + XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.") + } } diff --git a/UnitTests/Sources/AppLock/AppLockSettingsScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockSettingsScreenViewModelTests.swift index 460b77af5..e77ff8bb9 100644 --- a/UnitTests/Sources/AppLock/AppLockSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockSettingsScreenViewModelTests.swift @@ -19,12 +19,12 @@ import XCTest @testable import ElementX @MainActor -class AppLockSettingsScreenViewModelTests: XCTestCase { +class AppLockSetupSettingsScreenViewModelTests: XCTestCase { var appLockService: AppLockServiceProtocol! var keychainController: KeychainControllerMock! - var viewModel: AppLockSettingsScreenViewModelProtocol! + var viewModel: AppLockSetupSettingsScreenViewModelProtocol! - var context: AppLockSettingsScreenViewModelType.Context { + var context: AppLockSetupSettingsScreenViewModelType.Context { viewModel.context } @@ -36,7 +36,7 @@ class AppLockSettingsScreenViewModelTests: XCTestCase { keychainController = KeychainControllerMock() appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings) - viewModel = AppLockSettingsScreenViewModel(appLockService: AppLockServiceMock.mock()) + viewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock()) } override func tearDown() { diff --git a/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift index 03a712b9e..276a09638 100644 --- a/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockSetupPINScreenViewModelTests.swift @@ -37,7 +37,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase { } func testCreatePIN() async throws { - viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: appLockService) + viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm } @@ -53,7 +53,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase { } func testCreateWeakPIN() async throws { - viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: appLockService) + viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.") @@ -65,7 +65,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase { } func testCreatePINMismatch() async throws { - viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, appLockService: appLockService) + viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.") @@ -83,7 +83,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase { } func testUnlock() async throws { - viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, appLockService: appLockService) + viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService) let pinCode = "2023" keychainController.pinCodeReturnValue = pinCode keychainController.containsPINCodeReturnValue = true diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png index f267b11ab..d4d5a8ab8 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Confirm.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c78cada10d7c15137a635b0e841fe20e124814921d953296fca5ed16dea93fef -size 98997 +oid sha256:3f1fa2ac91c3c5966db4e7cf34e8f4d724547d583147ac23bd392c536b0357bb +size 105733 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png index 40d87b8d9..6f11f2d57 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Create.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2aa8303cb959ebeae8a6b6bced04f2c3ecdfe429c97f153a38a2d650dddadf4 -size 99386 +oid sha256:a3de8520c297d0c53f500a5cbe1322d50ae1f76d7c4678ab7c7e1d243c6a8657 +size 106000 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png index 34b1777a4..ed3f0b3f1 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupPINScreen.Unlock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e53aa446554e19bc47b31f59038ec3caeeaba8c4c15a1884f5e510f30eac4c61 -size 72867 +oid sha256:641c74d77ed0b43113fda06653a3a7eda92906a81a682cbd6b781aabcce51ab0 +size 76727 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Face-ID.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Face-ID.png new file mode 100644 index 000000000..39382eabc --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Face-ID.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:905d8d16cfce1540a91a3c8ef7d4295b844d2aab4c3ed3035230277acb56e228 +size 94357 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.PIN-only.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.PIN-only.png new file mode 100644 index 000000000..d5eec5905 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.PIN-only.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f918886d8da2987f6ae26762267557ce2f7d7f0b02ce985de56caa3fb7ee305c +size 83101 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Touch-ID-Mandatory.png b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Touch-ID-Mandatory.png new file mode 100644 index 000000000..ded18677b --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_appLockSetupSettingsScreen.Touch-ID-Mandatory.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08af67d397dafe03f60b36e2d1650a9031c12041ce5e015f6c2f81af4e9731fa +size 88510 diff --git a/changelog.d/pr-1949.wip b/changelog.d/pr-1949.wip new file mode 100644 index 000000000..d3c9907c8 --- /dev/null +++ b/changelog.d/pr-1949.wip @@ -0,0 +1 @@ +Add an AppLockSetupFlowCoordinator for creating a PIN with both mandatory and optional flows. \ No newline at end of file