diff --git a/Dangerfile.swift b/Dangerfile.swift index 6fa7301f31..c0d03bc8e1 100644 --- a/Dangerfile.swift +++ b/Dangerfile.swift @@ -6,7 +6,7 @@ SwiftLint.lint(inline: true) let danger = Danger() // Warn when there is a big PR -if (danger.github.pullRequest.additions ?? 0) > 500 { +if (danger.github.pullRequest.additions ?? 0) > 1000 { warn("This pull request seems relatively large. Please consider splitting it into multiple smaller ones.") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f7d1bd2490..67287a142c 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 0EAEA507586717B055441970 /* AppLockScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80AD634BF0A1767FE8941C5 /* AppLockScreenCoordinator.swift */; }; 0ED691ADC9C2EA457E7A9427 /* FormattingToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AE449DFBA7CC863EEB2FD2A /* FormattingToolbar.swift */; }; 0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */; }; + 0EEC614342F823E5BF966C2C /* AppLockTimerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A5B4CD611DE7E94F5BA87B2 /* AppLockTimerTests.swift */; }; 0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFCAA239095A116976E32C4 /* BackgroundTaskTests.swift */; }; 1146E9EDCF8344F7D6E0D553 /* MockCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0376C429FAB1687C3D905F3E /* MockCoder.swift */; }; 119AE9A3FC6E0606C1146528 /* NotificationSettingsEditScreenRoomCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97F8963B14EB0AF3940DDBF /* NotificationSettingsEditScreenRoomCell.swift */; }; @@ -408,6 +409,7 @@ 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF1EE0AA78470C674554262 /* PillTextAttachment.swift */; }; 7719778A682FDAC21445E9C8 /* OnboardingLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0D7955FFB19B584594844B /* OnboardingLogo.swift */; }; 7756C4E90CABE6F14F7920A0 /* BugReportUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FEA87EA3752203065ECE27 /* BugReportUITests.swift */; }; + 77693820498ABF3508814D49 /* AppLockServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97F9661ABF08CE002054A2 /* AppLockServiceTests.swift */; }; 77920AFA8091AC6B9F190C90 /* Signposter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */; }; 77BB228AEA861E50FFD6A228 /* HomeScreenEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */; }; 77C1A2F49CD90D3EFDF376E5 /* MapTilerURLBuildersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376D941BF8BB294389C0DE24 /* MapTilerURLBuildersTests.swift */; }; @@ -451,6 +453,7 @@ 83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; }; 8421FFCD5360A15D170922A8 /* ProgressMaskModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A1D75C7C52CD14A327CC90 /* ProgressMaskModifier.swift */; }; 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; + 8478992479B296C45150208F /* AppLockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */; }; 84CAE3E96D93194DA06B9194 /* CallScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9AD6AE5FC868962F090740 /* CallScreenViewModelProtocol.swift */; }; 84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */; }; @@ -603,7 +606,6 @@ A9A5801D5EE3D4D91F6DDADB /* AnalyticsSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C2527813FDAE23E72A9063 /* AnalyticsSettingsScreenViewModelTests.swift */; }; A9D349478F7D4A2B1E40CEF9 /* LegalInformationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8977176AB534AA41630395BC /* LegalInformationScreenViewModelProtocol.swift */; }; AA050DF4AEE54A641BA7CA22 /* RoomSummaryProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */; }; - AA64AAE1C4BB96C7F2761CAB /* AppLockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4BEE95A091150EEBF1C358 /* AppLockScreenViewModelTests.swift */; }; AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */; }; AAF0BBED840DF4A53EE85E77 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = C2C69B8BA5A9702E7A8BC08F /* MatrixRustSDK */; }; ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */; }; @@ -846,6 +848,7 @@ EF0D0155DD104C7A41A2EB0E /* PlainMentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE78FA0011E07920AE83135 /* PlainMentionBuilder.swift */; }; EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */; }; EF7924005216B8189898F370 /* BackgroundTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */; }; + EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */; }; F05516474DB42369FD976CEF /* AppLockScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349C633291427A0F29C28C54 /* AppLockScreenUITests.swift */; }; F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; @@ -1186,6 +1189,7 @@ 4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramePreferenceKey.swift; sourceTree = ""; }; 47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModel.swift; sourceTree = ""; }; + 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTimer.swift; sourceTree = ""; }; 4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenModels.swift; sourceTree = ""; }; 49ABAB186CF00B15C5521D04 /* MenuSheetLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSheetLabelStyle.swift; sourceTree = ""; }; @@ -1194,6 +1198,7 @@ 49E6066092ED45E36BB306F7 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.stringsdict"; sourceTree = ""; }; 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = ""; }; 4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMacContextMenu.swift; sourceTree = ""; }; + 4A5B4CD611DE7E94F5BA87B2 /* AppLockTimerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTimerTests.swift; sourceTree = ""; }; 4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaLoader.swift; sourceTree = ""; }; 4ADC55DFF46083BC957E0019 /* CreatePollScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreenModels.swift; sourceTree = ""; }; 4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = ""; }; @@ -1206,7 +1211,6 @@ 4E47F18A9A077E351CEA10D4 /* TextBasedRoomTimelineViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineViewProtocol.swift; sourceTree = ""; }; 4E625B0EB2F86B37C14EF7E6 /* SettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModel.swift; sourceTree = ""; }; 4F0CB536D1C3CC15AA740CC6 /* AuthenticationServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxyProtocol.swift; sourceTree = ""; }; - 4F4BEE95A091150EEBF1C358 /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -1479,6 +1483,7 @@ AAE73D571D4F9C36DD45255A /* BackgroundTaskServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskServiceProtocol.swift; sourceTree = ""; }; AB8E75B9CB6C78BE8D09B1AF /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; + AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = ""; }; AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = ""; }; AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = ""; }; @@ -1661,6 +1666,7 @@ DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = ""; }; DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = ""; }; DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModelProtocol.swift; sourceTree = ""; }; + DD97F9661ABF08CE002054A2 /* AppLockServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockServiceTests.swift; sourceTree = ""; }; DE846DDA83BFD7EC5C03760B /* ServerConfirmationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenUITests.swift; sourceTree = ""; }; DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileHandleProxy.swift; sourceTree = ""; }; DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationViewModelTests.swift; sourceTree = ""; }; @@ -2904,7 +2910,6 @@ children = ( 58C2527813FDAE23E72A9063 /* AnalyticsSettingsScreenViewModelTests.swift */, C687844F60BFF532D49A994C /* AnalyticsTests.swift */, - 4F4BEE95A091150EEBF1C358 /* AppLockScreenViewModelTests.swift */, E461B3C8BBBFCA400B268D14 /* AppRouteURLParserTests.swift */, 893777A4997BBDB68079D4F5 /* ArrayTests.swift */, AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */, @@ -2978,6 +2983,7 @@ C796FC1DFDBCDD5573D0360F /* WaitlistScreenViewModelTests.swift */, 851EF6258DF8B7EF129DC3AC /* WelcomeScreenScreenViewModelTests.swift */, 53280D2292E6C9C7821773FD /* UserSession */, + 9613851C68D8C01EABFB3569 /* AppLock */, 70C5B842301AC281DF374E41 /* Extensions */, A6AA0A048CAE428A5CA4CBBB /* LayoutTests */, 7583EAC171059A86B767209F /* MediaProvider */, @@ -3045,6 +3051,7 @@ isa = PBXGroup; children = ( 851B95BB98649B8E773D6790 /* AppLockService.swift */, + 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */, ); path = AppLock; sourceTree = ""; @@ -3448,6 +3455,16 @@ path = TimelineItems; sourceTree = ""; }; + 9613851C68D8C01EABFB3569 /* AppLock */ = { + isa = PBXGroup; + children = ( + AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */, + DD97F9661ABF08CE002054A2 /* AppLockServiceTests.swift */, + 4A5B4CD611DE7E94F5BA87B2 /* AppLockTimerTests.swift */, + ); + path = AppLock; + sourceTree = ""; + }; 99B9B46F2D621380428E68F7 /* ElementX */ = { isa = PBXGroup; children = ( @@ -4739,7 +4756,9 @@ files = ( A9A5801D5EE3D4D91F6DDADB /* AnalyticsSettingsScreenViewModelTests.swift in Sources */, 890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */, - AA64AAE1C4BB96C7F2761CAB /* AppLockScreenViewModelTests.swift in Sources */, + 8478992479B296C45150208F /* AppLockScreenViewModelTests.swift in Sources */, + 77693820498ABF3508814D49 /* AppLockServiceTests.swift in Sources */, + 0EEC614342F823E5BF966C2C /* AppLockTimerTests.swift in Sources */, EA78A7512AFB1E5451744EB1 /* AppRouteURLParserTests.swift in Sources */, 3EC698F80DDEEFA273857841 /* ArrayTests.swift in Sources */, 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */, @@ -4884,6 +4903,7 @@ BE641CE5F9036B9AD7367DF1 /* AppLockScreenViewModel.swift in Sources */, 33094DB91C3A4131E76B2C07 /* AppLockScreenViewModelProtocol.swift in Sources */, 1D623953F970D11F6F38499C /* AppLockService.swift in Sources */, + EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */, 355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */, 12CCA59536EDD99A3272CF77 /* AppSettings.swift in Sources */, 9462C62798F47E39DCC182D2 /* Application.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index eef3347771..c94c639430 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -117,6 +117,13 @@ final class AppSettings { /// An email address that should be used for support requests. let supportEmailAddress = "support@element.io" + // MARK: - Security + + /// 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"] + // MARK: - Authentication /// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication. diff --git a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift index f489c0fcb6..5eff1ac3bc 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift @@ -42,13 +42,13 @@ class AppLockFlowCoordinator: CoordinatorProtocol { NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) .sink { [weak self] _ in - self?.showPlaceholderIfNeeded() + self?.applicationDidEnterBackground() } .store(in: &cancellables) NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) .sink { [weak self] _ in - self?.showUnlockScreenIfNeeded() + self?.applicationWillEnterForeground() } .store(in: &cancellables) } @@ -59,18 +59,26 @@ class AppLockFlowCoordinator: CoordinatorProtocol { // MARK: - App unlock - /// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher. - private func showPlaceholderIfNeeded() { + private func applicationDidEnterBackground() { guard appLockService.isEnabled else { return } + appLockService.applicationDidEnterBackground() + showPlaceholder() + } + + private func applicationWillEnterForeground() { + guard appLockService.isEnabled, appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else { return } + showUnlockScreen() + } + + /// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher. + private func showPlaceholder() { navigationCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(), animated: false) actionsSubject.send(.lockApp) } /// Displays the unlock flow with the main unlock screen. - private func showUnlockScreenIfNeeded() { - guard appLockService.isEnabled, appLockService.needsUnlock else { return } - + private func showUnlockScreen() { let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService)) coordinator.actions.sink { [weak self] action in guard let self else { return } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 0d5483ad7d..85e7e22aaa 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -564,6 +564,88 @@ class KeychainControllerMock: KeychainControllerProtocol { removeAllRestorationTokensCallsCount += 1 removeAllRestorationTokensClosure?() } + //MARK: - resetSecrets + + var resetSecretsCallsCount = 0 + var resetSecretsCalled: Bool { + return resetSecretsCallsCount > 0 + } + var resetSecretsClosure: (() -> Void)? + + func resetSecrets() { + resetSecretsCallsCount += 1 + resetSecretsClosure?() + } + //MARK: - containsPINCode + + var containsPINCodeThrowableError: Error? + var containsPINCodeCallsCount = 0 + var containsPINCodeCalled: Bool { + return containsPINCodeCallsCount > 0 + } + var containsPINCodeReturnValue: Bool! + var containsPINCodeClosure: (() throws -> Bool)? + + func containsPINCode() throws -> Bool { + if let error = containsPINCodeThrowableError { + throw error + } + containsPINCodeCallsCount += 1 + if let containsPINCodeClosure = containsPINCodeClosure { + return try containsPINCodeClosure() + } else { + return containsPINCodeReturnValue + } + } + //MARK: - setPINCode + + var setPINCodeThrowableError: Error? + var setPINCodeCallsCount = 0 + var setPINCodeCalled: Bool { + return setPINCodeCallsCount > 0 + } + var setPINCodeReceivedPinCode: String? + var setPINCodeReceivedInvocations: [String] = [] + var setPINCodeClosure: ((String) throws -> Void)? + + func setPINCode(_ pinCode: String) throws { + if let error = setPINCodeThrowableError { + throw error + } + setPINCodeCallsCount += 1 + setPINCodeReceivedPinCode = pinCode + setPINCodeReceivedInvocations.append(pinCode) + try setPINCodeClosure?(pinCode) + } + //MARK: - pinCode + + var pinCodeCallsCount = 0 + var pinCodeCalled: Bool { + return pinCodeCallsCount > 0 + } + var pinCodeReturnValue: String? + var pinCodeClosure: (() -> String?)? + + func pinCode() -> String? { + pinCodeCallsCount += 1 + if let pinCodeClosure = pinCodeClosure { + return pinCodeClosure() + } else { + return pinCodeReturnValue + } + } + //MARK: - removePINCode + + var removePINCodeCallsCount = 0 + var removePINCodeCalled: Bool { + return removePINCodeCallsCount > 0 + } + var removePINCodeClosure: (() -> Void)? + + func removePINCode() { + removePINCodeCallsCount += 1 + removePINCodeClosure?() + } } class MediaPlayerMock: MediaPlayerProtocol { var mediaSource: MediaSourceProxy? diff --git a/ElementX/Sources/Services/AppLock/AppLockService.swift b/ElementX/Sources/Services/AppLock/AppLockService.swift index ca4372ab72..adbad8b20d 100644 --- a/ElementX/Sources/Services/AppLock/AppLockService.swift +++ b/ElementX/Sources/Services/AppLock/AppLockService.swift @@ -16,14 +16,33 @@ import LocalAuthentication +enum AppLockServiceError: Error { + /// The operation failed to access the keychain. + case keychainError + /// The PIN code was rejected because it isn't long enough, or contains invalid characters. + case invalidPIN + /// The PIN code was rejected as an insecure choice. + case weakPIN +} + @MainActor protocol AppLockServiceProtocol { /// The app has been configured to automatically lock with a PIN code. var isEnabled: Bool { get } - /// The app can additionally be unlocked using FaceID or TouchID. - var supportsBiometrics: Bool { get } - /// The app should be unlocked with a PIN code/biometrics before being presented. - var needsUnlock: 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 } + + /// Sets the user's PIN code used to unlock the app. + func setupPINCode(_ pinCode: String) -> Result + /// Disables the App Lock feature, removing the user's stored PIN code. + func disable() + + /// Informs the service that the app has entered the background. + func applicationDidEnterBackground() + /// Decides whether the app should be unlocked with a PIN code/biometrics on foregrounding. + func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool /// Attempt to unlock the app with the supplied PIN code. func unlock(with pinCode: String) -> Bool @@ -31,25 +50,80 @@ protocol AppLockServiceProtocol { func unlockWithBiometrics() -> Bool } +/// The service responsible for locking and unlocking the app. class AppLockService: AppLockServiceProtocol { private let keychainController: KeychainControllerProtocol private let appSettings: AppSettings + private let context = LAContext() + + private let timer: AppLockTimer - var isEnabled: Bool { appSettings.appLockFlowEnabled } - var supportsBiometrics: Bool { true } - var needsUnlock: Bool { true } + var isEnabled: Bool { + do { + guard appSettings.appLockFlowEnabled else { return false } + return try keychainController.containsPINCode() + } catch { + MXLog.error("Keychain access error: \(error)") + MXLog.error("Locking the app.") + return true + } + } + + var biometryType: LABiometryType { context.biometryType } + var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet. init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) { self.keychainController = keychainController self.appSettings = appSettings + timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod) + } + + func setupPINCode(_ pinCode: String) -> Result { + guard validate(pinCode) else { return .failure(.invalidPIN) } + guard !appSettings.appLockPINCodeBlockList.contains(pinCode) else { return .failure(.weakPIN) } + + do { + try keychainController.setPINCode(pinCode) + return .success(()) + } catch { + MXLog.error("Keychain access error: \(error)") + return .failure(.keychainError) + } + } + + func disable() { + biometricUnlockEnabled = false + keychainController.removePINCode() + } + + func applicationDidEnterBackground() { + timer.applicationDidEnterBackground() + } + + func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool { + timer.computeLockState(willEnterForegroundAt: date) } func unlock(with pinCode: String) -> Bool { - true + guard pinCode == keychainController.pinCode() else { return false } + return completeUnlock() } func unlockWithBiometrics() -> Bool { - guard supportsBiometrics else { return false } + guard biometryType != .none, biometricUnlockEnabled else { return false } + return completeUnlock() + } + + // MARK: - Private + + /// Ensures that a provided PIN code is long enough and only contains digits. + private func validate(_ pinCode: String) -> Bool { + pinCode.count == 4 && pinCode.allSatisfy(\.isNumber) + } + + /// Shared logic for completing an unlock via a PIN or biometry. + private func completeUnlock() -> Bool { + timer.registerUnlock() return true } } diff --git a/ElementX/Sources/Services/AppLock/AppLockTimer.swift b/ElementX/Sources/Services/AppLock/AppLockTimer.swift new file mode 100644 index 0000000000..9df2fcede5 --- /dev/null +++ b/ElementX/Sources/Services/AppLock/AppLockTimer.swift @@ -0,0 +1,62 @@ +// +// 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 Foundation + +/// A timer that adds a grace-period to the app before locking it. +class AppLockTimer { + /// The amount of time the app should remain unlocked for whilst backgrounded. + let gracePeriod: TimeInterval + + /// Whether the timer considers the app to be locked or not. + /// + /// Internally this value may be incorrect, always call `needsUnlock` to get the correct value. + private var isLocked = true + /// The date when the app was last backgrounded whilst in an unlocked state. + private var lastUnlockedBackground: Date? + + /// Creates a new timer. + /// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded. + init(gracePeriod: TimeInterval) { + self.gracePeriod = 180 + } + + /// Signals to the timer to track how long the app will be backgrounded for. + func applicationDidEnterBackground(date: Date = .now) { + // Only update the last background date if the app is currently unlocked. + guard !isLocked else { return } + lastUnlockedBackground = date + } + + /// Asks the timer to recompute the lock state on foregrounding. + func computeLockState(willEnterForegroundAt date: Date) -> Bool { + guard !isLocked, let lastUnlockedBackground else { return true } + + isLocked = date.timeIntervalSince(lastUnlockedBackground) >= gracePeriod + + // Don't allow changing the device's clock to unlock the app. + if date < lastUnlockedBackground { + isLocked = true + } + + return isLocked + } + + /// Registers a successful unlock with the timer. + func registerUnlock() { + isLocked = false + } +} diff --git a/ElementX/Sources/Services/Keychain/KeychainController.swift b/ElementX/Sources/Services/Keychain/KeychainController.swift index f1545b123d..99e87eabe8 100644 --- a/ElementX/Sources/Services/Keychain/KeychainController.swift +++ b/ElementX/Sources/Services/Keychain/KeychainController.swift @@ -22,24 +22,36 @@ enum KeychainControllerService: String { case sessions case tests - var identifier: String { + var restorationTokenID: String { InfoPlistReader.main.baseBundleIdentifier + "." + rawValue } + + var mainID: String { + InfoPlistReader.main.baseBundleIdentifier + ".keychain.\(rawValue)" + } } class KeychainController: KeychainControllerProtocol { - private let keychain: Keychain + /// The keychain responsible for storing account restoration tokens (keyed by userID). + private let restorationTokenKeychain: Keychain + /// The keychain responsible for storing all other secrets in the app (keyed by `Key`s). + private let mainKeychain: Keychain + + private enum Key: String { + case pinCode + } - init(service: KeychainControllerService, - accessGroup: String) { - keychain = Keychain(service: service.identifier, - accessGroup: accessGroup) + init(service: KeychainControllerService, accessGroup: String) { + restorationTokenKeychain = Keychain(service: service.restorationTokenID, accessGroup: accessGroup) + mainKeychain = Keychain(service: service.mainID, accessGroup: accessGroup) } + + // MARK: - Restoration Tokens func setRestorationToken(_ restorationToken: RestorationToken, forUsername username: String) { do { let tokenData = try JSONEncoder().encode(restorationToken) - try keychain.set(tokenData, key: username) + try restorationTokenKeychain.set(tokenData, key: username) } catch { MXLog.error("Failed storing user restore token with error: \(error)") } @@ -47,7 +59,7 @@ class KeychainController: KeychainControllerProtocol { func restorationTokenForUsername(_ username: String) -> RestorationToken? { do { - guard let tokenData = try keychain.getData(username) else { + guard let tokenData = try restorationTokenKeychain.getData(username) else { return nil } @@ -59,7 +71,7 @@ class KeychainController: KeychainControllerProtocol { } func restorationTokens() -> [KeychainCredentials] { - keychain.allKeys().compactMap { username in + restorationTokenKeychain.allKeys().compactMap { username in guard let restorationToken = restorationTokenForUsername(username) else { return nil } @@ -72,7 +84,7 @@ class KeychainController: KeychainControllerProtocol { MXLog.warning("Removing restoration token for user: \(username).") do { - try keychain.remove(username) + try restorationTokenKeychain.remove(username) } catch { MXLog.error("Failed removing restore token with error: \(error)") } @@ -82,7 +94,7 @@ class KeychainController: KeychainControllerProtocol { MXLog.warning("Removing all user restoration tokens.") do { - try keychain.removeAll() + try restorationTokenKeychain.removeAll() } catch { MXLog.error("Failed removing all tokens") } @@ -103,4 +115,41 @@ class KeychainController: KeychainControllerProtocol { let restorationToken = RestorationToken(session: session) setRestorationToken(restorationToken, forUsername: session.userId) } + + // MARK: - App Secrets + + func resetSecrets() { + MXLog.warning("Resetting main keychain.") + + do { + try mainKeychain.removeAll() + } catch { + MXLog.error("Failed resetting the main keychain.") + } + } + + func containsPINCode() throws -> Bool { + try mainKeychain.contains(Key.pinCode.rawValue) + } + + func setPINCode(_ pinCode: String) throws { + try mainKeychain.set(pinCode, key: Key.pinCode.rawValue) + } + + func pinCode() -> String? { + do { + return try mainKeychain.getString(Key.pinCode.rawValue) + } catch { + MXLog.error("Failed retrieving the PIN code.") + return nil + } + } + + func removePINCode() { + do { + try mainKeychain.remove(Key.pinCode.rawValue) + } catch { + MXLog.error("Failed removing the PIN code.") + } + } } diff --git a/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift b/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift index f804cff105..f77813af9c 100644 --- a/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift +++ b/ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift @@ -24,9 +24,24 @@ struct KeychainCredentials { // sourcery: AutoMockable protocol KeychainControllerProtocol: ClientSessionDelegate { + // MARK: Restoration Tokens + func setRestorationToken(_ restorationToken: RestorationToken, forUsername: String) func restorationTokenForUsername(_ username: String) -> RestorationToken? func restorationTokens() -> [KeychainCredentials] func removeRestorationTokenForUsername(_ username: String) func removeAllRestorationTokens() + + // MARK: App Secrets + + /// Removes everything from the keychain excluding any restoration tokens. + func resetSecrets() + /// Whether or not an App Lock PIN code has been set. + func containsPINCode() throws -> Bool + /// Sets a new PIN code for App Lock. + func setPINCode(_ pinCode: String) throws + /// The PIN code required to unlock the app. + func pinCode() -> String? + /// Removes the App Lock PIN code. + func removePINCode() } diff --git a/UnitTests/Sources/AppLockScreenViewModelTests.swift b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift similarity index 82% rename from UnitTests/Sources/AppLockScreenViewModelTests.swift rename to UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift index 566b82338a..8016c7d73a 100644 --- a/UnitTests/Sources/AppLockScreenViewModelTests.swift +++ b/UnitTests/Sources/AppLock/AppLockScreenViewModelTests.swift @@ -21,19 +21,22 @@ import XCTest @MainActor class AppLockScreenViewModelTests: XCTestCase { var appLockService: AppLockService! + var keychainController: KeychainControllerMock! var viewModel: AppLockScreenViewModelProtocol! var context: AppLockScreenViewModelType.Context { viewModel.context } override func setUp() { AppSettings.reset() - appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: AppSettings()) + keychainController = KeychainControllerMock() + appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings()) viewModel = AppLockScreenViewModel(appLockService: appLockService) } func testUnlock() async throws { // Given a valid PIN code. - let pinCode = "0000" + let pinCode = "2023" + keychainController.pinCodeReturnValue = pinCode // When entering it on the lock screen. let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked } diff --git a/UnitTests/Sources/AppLock/AppLockServiceTests.swift b/UnitTests/Sources/AppLock/AppLockServiceTests.swift new file mode 100644 index 0000000000..6c3ffaceee --- /dev/null +++ b/UnitTests/Sources/AppLock/AppLockServiceTests.swift @@ -0,0 +1,178 @@ +// +// 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 XCTest + +@testable import ElementX + +@MainActor +class AppLockServiceTests: XCTestCase { + var appSettings: AppSettings! + var service: AppLockService! + + override func setUp() { + AppSettings.reset() + appSettings = AppSettings() + appSettings.appLockFlowEnabled = true + + let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) + + service = AppLockService(keychainController: keychainController, appSettings: appSettings) + service.disable() + } + + override func tearDown() { + AppSettings.reset() + } + + func testValidPINCode() { + // Given a service that hasn't been enabled. + XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.") + + // When setting a PIN code. + let pinCode = "2023" // Highly secure PIN that is rotated every 12 months. + guard case .success = service.setupPINCode(pinCode) else { + XCTFail("The PIN should be valid.") + return + } + + // Then service should be enabled and only the provided PIN should work to unlock the app. + XCTAssertTrue(service.isEnabled, "The service should become enabled when setting a PIN.") + XCTAssertTrue(service.unlock(with: pinCode), "The provided PIN code should work.") + XCTAssertFalse(service.unlock(with: "2024"), "No other PIN code should work.") + XCTAssertFalse(service.unlock(with: "1234"), "No other PIN code should work.") + XCTAssertFalse(service.unlock(with: "9999"), "No other PIN code should work.") + } + + func testWeakPINCode() { + // Given a service that hasn't been enabled. + XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.") + + // When setting a PIN code that is in the block list. + let pinCode = appSettings.appLockPINCodeBlockList[0] + let result = service.setupPINCode(pinCode) + + // Then the setup should fail and the service be left as disabled. + guard case let .failure(error) = result else { + XCTFail("The call should have failed.") + return + } + XCTAssertEqual(error, .weakPIN, "The PIN should be rejected as weak.") + XCTAssertFalse(service.isEnabled, "The service should remain disabled.") + } + + func testShortPINCode() { + // Given a service that hasn't been enabled. + XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.") + + // When setting a PIN code that is too short + let pinCode = "123" + let result = service.setupPINCode(pinCode) + + // Then the setup should fail and the service be left as disabled. + guard case let .failure(error) = result else { + XCTFail("The call should have failed.") + return + } + XCTAssertEqual(error, .invalidPIN, "The PIN should be rejected as invalid.") + XCTAssertFalse(service.isEnabled, "The service should remain disabled.") + } + + func testNonNumericPINCode() { + // Given a service that hasn't been enabled. + XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.") + + // When setting a PIN code that is too short + let pinCode = "abcd" + let result = service.setupPINCode(pinCode) + + // Then the setup should fail and the service be left as disabled. + guard case let .failure(error) = result else { + XCTFail("The call should have failed.") + return + } + XCTAssertEqual(error, .invalidPIN, "The PIN should be rejected as invalid.") + XCTAssertFalse(service.isEnabled, "The service should remain disabled.") + } + + func testChangePINCode() { + // Given a service that is already enabled with a PIN. + let pinCode = "2023" + let newPINCode = "2024" + guard case .success = service.setupPINCode(pinCode) else { + XCTFail("The PIN should be valid.") + return + } + XCTAssertTrue(service.isEnabled, "The service should be enabled.") + XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.") + XCTAssertFalse(service.unlock(with: newPINCode), "The PIN we're about to set should not work.") + + // When updating the PIN code. + guard case .success = service.setupPINCode(newPINCode) else { + XCTFail("The PIN should be valid.") + return + } + + // Then the old code should not be accepted. + XCTAssertTrue(service.isEnabled, "The service should remain enabled.") + XCTAssertTrue(service.unlock(with: newPINCode), "The new PIN should work.") + XCTAssertFalse(service.unlock(with: pinCode), "The original PIN should be rejected.") + } + + func testInvalidChangePINCode() { + // Given a service that is already enabled with a PIN. + let pinCode = "2023" + let invalidPIN = appSettings.appLockPINCodeBlockList[0] + guard case .success = service.setupPINCode(pinCode) else { + XCTFail("The PIN should be valid.") + return + } + XCTAssertTrue(service.isEnabled, "The service should be enabled.") + XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.") + XCTAssertFalse(service.unlock(with: invalidPIN), "The PIN we're about to set should not work.") + + // When updating the PIN code that is in the block list. + let result = service.setupPINCode(invalidPIN) + + // Then it should fail and nothing should change. + guard case let .failure(error) = result else { + XCTFail("The call should have failed.") + return + } + XCTAssertEqual(error, .weakPIN, "The PIN should be rejected as weak.") + XCTAssertTrue(service.isEnabled, "The service should remain enabled.") + XCTAssertFalse(service.unlock(with: invalidPIN), "The rejected PIN shouldn't work.") + XCTAssertTrue(service.unlock(with: pinCode), "The original PIN should continue to work.") + } + + func testDisablePINCode() { + // Given a service that is already enabled with a PIN. + let pinCode = "2023" + guard case .success = service.setupPINCode(pinCode) else { + XCTFail("The PIN should be valid.") + return + } + XCTAssertTrue(service.isEnabled, "The service should be enabled.") + XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.") + + // When disabling the PIN code. + service.disable() + + // Then the PIN code should be removed. + XCTAssertFalse(service.isEnabled, "The service should no longer be enabled.") + XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.") + } +} diff --git a/UnitTests/Sources/AppLock/AppLockTimerTests.swift b/UnitTests/Sources/AppLock/AppLockTimerTests.swift new file mode 100644 index 0000000000..54b22faf24 --- /dev/null +++ b/UnitTests/Sources/AppLock/AppLockTimerTests.swift @@ -0,0 +1,154 @@ +// +// 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 XCTest + +@testable import ElementX + +class AppLockTimerTests: XCTestCase { + var timer: AppLockTimer! + + let now = Date.now + + var gracePeriod: TimeInterval { timer.gracePeriod } + var halfGracePeriod: TimeInterval { gracePeriod / 2 } + var gracePeriodX2: TimeInterval { gracePeriod * 2 } + var gracePeriodX10: TimeInterval { gracePeriod * 10 } + + override func tearDown() { + timer = nil + } + + func testTimerLockedOnStartup() { + setupTimer(unlocked: false) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now), + "The app should be locked on a fresh launch.") + + setupTimer(unlocked: false) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1), + "The app should be locked after a fresh launch.") + + setupTimer(unlocked: false) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod), + "The app should be locked after a fresh launch.") + + setupTimer(unlocked: false) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod), + "The app should be locked after a fresh launch.") + + setupTimer(unlocked: false) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10), + "The app should be locked after a fresh launch.") + } + + func testTimerBeforeFirstUnlock() { + setupTimer(unlocked: false, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now), + "The app should always remain locked after backgrounding when locked.") + + setupTimer(unlocked: false, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1), + "The app should always remain locked after backgrounding when locked.") + + setupTimer(unlocked: false, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod), + "The app should always remain locked after backgrounding when locked.") + + setupTimer(unlocked: false, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod), + "The app should always remain locked after backgrounding when locked.") + + setupTimer(unlocked: false, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10), + "The app should always remain locked after backgrounding when locked.") + } + + func testTimerWhenUnlocked() { + setupTimer(unlocked: true, backgroundedAt: now) + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: now + 1), + "The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.") + + setupTimer(unlocked: true, backgroundedAt: now) + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod), + "The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.") + + setupTimer(unlocked: true, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod), + "The app should become locked when it was unlocked and backgrounded for more than the grace period.") + + setupTimer(unlocked: true, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10), + "The app should become locked when it was unlocked and backgrounded for more than the grace period.") + } + + func testTimerRepeatingWithinGracePeriod() { + setupTimer(unlocked: true, backgroundedAt: now) + + var nextCheck = now + halfGracePeriod + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), + "The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.") + timer.applicationDidEnterBackground(date: nextCheck) + + nextCheck = now + gracePeriod + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), + "The app should remain unlocked when repeating the backgrounded and foreground within the grace period.") + timer.applicationDidEnterBackground(date: nextCheck) + + nextCheck = now + gracePeriod + halfGracePeriod + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), + "The app should remain unlocked when repeating the backgrounded and foreground within the grace period.") + timer.applicationDidEnterBackground(date: nextCheck) + + nextCheck = now + gracePeriodX2 + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), + "The app should remain unlocked when repeating the backgrounded and foreground within the grace period.") + timer.applicationDidEnterBackground(date: nextCheck) + + nextCheck = now + gracePeriodX10 + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: nextCheck), + "The app should become locked however when finally staying backgrounded for longer than the grace period.") + } + + func testTimerWithLongForeground() { + setupTimer(unlocked: true) + + let backgroundDate = now + gracePeriodX10 + timer.applicationDidEnterBackground(date: backgroundDate) + + XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: backgroundDate + 1), + "The grace period should be measured from the time the app was backgrounded, and not when it was unlocked.") + } + + func testChangingTimeLocksApp() { + setupTimer(unlocked: true, backgroundedAt: now) + XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now - 1), + "The the device's clock is changed to before the app was backgrounded, the device should remain locked.") + } + + /// Sets up the timer for testing. + /// - Parameters: + /// - unlocked: Whether the timer should consider itself unlocked or not. + /// - backgroundedDate: If not nil, the timer will consider the app to have been backgrounded at the specified date. + private func setupTimer(unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) { + timer = AppLockTimer(gracePeriod: 180) + if unlocked { + timer.registerUnlock() + } + if let backgroundedDate { + timer.applicationDidEnterBackground(date: backgroundedDate) + } + } +} diff --git a/UnitTests/Sources/KeychainControllerTests.swift b/UnitTests/Sources/KeychainControllerTests.swift index 9897d7c27d..6c9aee6236 100644 --- a/UnitTests/Sources/KeychainControllerTests.swift +++ b/UnitTests/Sources/KeychainControllerTests.swift @@ -24,6 +24,7 @@ class KeychainControllerTests: XCTestCase { keychain = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) keychain.removeAllRestorationTokens() + keychain.resetSecrets() } func testAddRestorationToken() { @@ -113,4 +114,45 @@ class KeychainControllerTests: XCTestCase { XCTAssertNotNil(keychain.restorationTokenForUsername("@test3:example.com"), "The restoration token should not have been deleted.") XCTAssertNotNil(keychain.restorationTokenForUsername("@test4:example.com"), "The restoration token should not have been deleted.") } + + func testAddPINCode() throws { + // Given a keychain without a PIN code set. + try XCTAssertFalse(keychain.containsPINCode(), "A new keychain shouldn't contain a PIN code.") + XCTAssertNil(keychain.pinCode(), "A new keychain shouldn't return a PIN code.") + + // When setting a PIN code. + try keychain.setPINCode("0000") + + // The the PIN code should be stored. + try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.") + XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.") + } + + func testUpdatePINCode() throws { + // Given a keychain with a PIN code already set. + try keychain.setPINCode("0000") + try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.") + XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.") + + // When setting a different PIN code. + try keychain.setPINCode("1234") + + // The the PIN code should be updated. + try XCTAssertTrue(keychain.containsPINCode(), "The keychain should still contain the PIN code.") + XCTAssertEqual(keychain.pinCode(), "1234", "The stored PIN code should match the new value.") + } + + func testRemovePINCode() throws { + // Given a keychain with a PIN code already set. + try keychain.setPINCode("0000") + try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.") + XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.") + + // When removing the PIN code. + keychain.removePINCode() + + // The the PIN code should no longer be stored. + try XCTAssertFalse(keychain.containsPINCode(), "The keychain should no longer contain the PIN code.") + XCTAssertNil(keychain.pinCode(), "There shouldn't be a stored PIN code after removing it.") + } } diff --git a/changelog.d/pr-1912.wip b/changelog.d/pr-1912.wip new file mode 100644 index 0000000000..7cb5d55c6b --- /dev/null +++ b/changelog.d/pr-1912.wip @@ -0,0 +1 @@ +Initial service implementation for using a PIN code \ No newline at end of file