diff --git a/BrowserKit/Sources/Shared/Prefs.swift b/BrowserKit/Sources/Shared/Prefs.swift index 1192dbf313e86..67e50ea8e6386 100644 --- a/BrowserKit/Sources/Shared/Prefs.swift +++ b/BrowserKit/Sources/Shared/Prefs.swift @@ -165,6 +165,7 @@ public struct PrefsKeys { // Firefox settings public struct Settings { public static let closePrivateTabs = "ClosePrivateTabs" + public static let lockPrivateTabs = "LockPrivateTabs" public static let sentFromFirefoxWhatsApp = "SentFromFirefoxWhatsApp" public static let navigationToolbarMiddleButton = "settings.navigationToolbarMiddleButton" public static let translationsFeature = "settings.translationFeature" diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index b382e1ef44395..c956b93396897 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -781,6 +781,11 @@ 74B195441CF503FC007F36EF /* RecentlyClosedTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B195431CF503FC007F36EF /* RecentlyClosedTabs.swift */; }; 74E36D781B71323500D69DA1 /* SettingsContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E36D771B71323500D69DA1 /* SettingsContentViewController.swift */; }; 74F80D342A0A52D700013C3D /* PrivacyPolicyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74F80D332A0A52D700013C3D /* PrivacyPolicyViewController.swift */; }; + 767742A92F4F649500DEC06D /* PrivateLockAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 767742A82F4F649500DEC06D /* PrivateLockAction.swift */; }; + 767742AB2F4F6B4B00DEC06D /* PrivateTabsLockOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 767742AA2F4F6B4B00DEC06D /* PrivateTabsLockOverlayView.swift */; }; + 76AFB6632F58B16C0064E6AC /* PrivateLockMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76AFB6622F58B16C0064E6AC /* PrivateLockMiddleware.swift */; }; + 76DC672D2F641E6B00BE6F6A /* PrivateLockDomainState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76DC672C2F641E6B00BE6F6A /* PrivateLockDomainState.swift */; }; + 76F1091F2F5C9F6600305C29 /* FirefoxGradientBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F1091E2F5C9F6600305C29 /* FirefoxGradientBackgroundView.swift */; }; 781C19CF2A780BEC0000DF46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 781C19CE2A780BEC0000DF46 /* Common */; }; 787EDD852943EE75002B93AE /* JumpBackInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787EDD832943EE75002B93AE /* JumpBackInTests.swift */; }; 789A0B232E2E969D004547CE /* FxUserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5423F62DF6EF17000AA578 /* FxUserState.swift */; }; @@ -8812,9 +8817,14 @@ 764643DCB449658AAA8ED829 /* bs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bs; path = bs.lproj/InfoPlist.strings; sourceTree = ""; }; 765243D790B967A69BD5DCF7 /* lv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lv; path = lv.lproj/ErrorPages.strings; sourceTree = ""; }; 766F49788D099E2201B74791 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Intro.strings; sourceTree = ""; }; + 767742A82F4F649500DEC06D /* PrivateLockAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateLockAction.swift; sourceTree = ""; }; + 767742AA2F4F6B4B00DEC06D /* PrivateTabsLockOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateTabsLockOverlayView.swift; sourceTree = ""; }; 76784C13A069B829DCA22A6C /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ka; path = ka.lproj/LoginManager.strings; sourceTree = ""; }; 7681461D8ADDD3E9662A2A53 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Shared.strings; sourceTree = ""; }; 7693448FA2E2865EA28005EE /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/ErrorPages.strings; sourceTree = ""; }; + 76AFB6622F58B16C0064E6AC /* PrivateLockMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateLockMiddleware.swift; sourceTree = ""; }; + 76DC672C2F641E6B00BE6F6A /* PrivateLockDomainState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateLockDomainState.swift; sourceTree = ""; }; + 76F1091E2F5C9F6600305C29 /* FirefoxGradientBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxGradientBackgroundView.swift; sourceTree = ""; }; 772744E9858179A908A070DB /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/ClearPrivateData.strings; sourceTree = ""; }; 77294827911067CD5994B5FD /* co */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = co; path = co.lproj/ClearHistoryConfirm.strings; sourceTree = ""; }; 779D4B8EAE580D7B2B55EC3F /* su */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = su; path = su.lproj/HistoryPanel.strings; sourceTree = ""; }; @@ -12203,6 +12213,8 @@ 1DDE3DB22AC34E1E0039363B /* TabCell.swift */, F605DD572CC73469009A671B /* TabDisplayDiffableDataSource.swift */, 213BF7522AC21D1B00C53A64 /* TabDisplayPanelViewController.swift */, + 767742AA2F4F6B4B00DEC06D /* PrivateTabsLockOverlayView.swift */, + 76F1091E2F5C9F6600305C29 /* FirefoxGradientBackgroundView.swift */, 214EF4142AC5D5D0005BCCDA /* TabDisplayView.swift */, 5A679E4A2B239FAE004F2B0D /* TabPeekViewController.swift */, 8A06DF5E2DE0C0EB007B7E9D /* TabTitleSupplementaryView.swift */, @@ -12917,6 +12929,7 @@ 5A2918CA2B522338002B197E /* GeneralBrowserAction.swift */, 7ADC1D182C27D35B003ED924 /* WebContextMenuActionsProvider.swift */, 8A8D277C2CC000BE0076AD3A /* NavigationBrowserAction.swift */, + 767742A82F4F649500DEC06D /* PrivateLockAction.swift */, ); path = Actions; sourceTree = ""; @@ -13048,6 +13061,14 @@ path = NativeErrorPage; sourceTree = ""; }; + 76AFB6612F58B1590064E6AC /* Middleware */ = { + isa = PBXGroup; + children = ( + 76AFB6622F58B16C0064E6AC /* PrivateLockMiddleware.swift */, + ); + path = Middleware; + sourceTree = ""; + }; 7AC7E04E2C160EB600051D4D /* Reader */ = { isa = PBXGroup; children = ( @@ -13149,6 +13170,7 @@ 81122E202B221AC0003DD9F8 /* SearchScreenState.swift */, 5A1947142B8FA9E0009C7A6C /* BrowserViewType.swift */, 8A8D277A2CBFFD710076AD3A /* BrowserNavigationType.swift */, + 76DC672C2F641E6B00BE6F6A /* PrivateLockDomainState.swift */, ); path = State; sourceTree = ""; @@ -15302,6 +15324,7 @@ 21365E382F30FD580000C369 /* BrowserViewControllerLayoutManager.swift */, 0BBFBF232DFC201300160911 /* WebEngineIntegration */, 5A2918C92B522326002B197E /* Actions */, + 76AFB6612F58B1590064E6AC /* Middleware */, 81122E1F2B2219AA003DD9F8 /* Views */, 81122E1E2B2219A0003DD9F8 /* State */, 81122E1D2B221998003DD9F8 /* Extensions */, @@ -18744,6 +18767,7 @@ 8AC6F2242D6E243F00D10A9F /* ExperimentEmptyPrivateTabsView.swift in Sources */, 1D0BA05C24F46A0400D731B5 /* TopSitesProvider.swift in Sources */, A093CD292F35408E0017774C /* MozAdsClientFactory.swift in Sources */, + 767742AB2F4F6B4B00DEC06D /* PrivateTabsLockOverlayView.swift in Sources */, DFACBF7F277B5F7B003D5F41 /* LegacyWallpaperBackgroundView.swift in Sources */, 8A7D08E32CAAF7C30035999C /* HomepageViewController.swift in Sources */, D01017F5219CB6BD009CBB5A /* DownloadContentScript.swift in Sources */, @@ -18965,6 +18989,7 @@ CDB3BE8724746787009320EE /* FirefoxAccountSignInViewController.swift in Sources */, 8A471183287F6D9C00F5A6EA /* BookmarksPanelViewModel.swift in Sources */, C705FD582EF354D300AC5EE7 /* PrivacyNoticeUpdate.swift in Sources */, + 76DC672D2F641E6B00BE6F6A /* PrivateLockDomainState.swift in Sources */, 0B8A39EF2D3514C100853E47 /* EditBookmarkDiffableDataSource.swift in Sources */, 8A44F20E2B585E1F0016BC81 /* HomepageTelemetry.swift in Sources */, EDD2A7FA2CDBD1D100ED464C /* SearchEngineElement+initFromSearchEngine.swift in Sources */, @@ -19503,6 +19528,7 @@ 8A19ACB62A3290F9001C2147 /* NotificationsSetting.swift in Sources */, 21365E392F30FD700000C369 /* BrowserViewControllerLayoutManager.swift in Sources */, 43D16B8229831E6A009F8279 /* CreditCardInputField.swift in Sources */, + 76F1091F2F5C9F6600305C29 /* FirefoxGradientBackgroundView.swift in Sources */, 8187561A2BB4618500DCD1F3 /* OnboardingViewControllerState.swift in Sources */, F82F68B12E86EF5F002E42D1 /* DeleteAutofillKeysSetting.swift in Sources */, EB98550124226EF70040F24B /* AppDelegate+SyncSentTabs.swift in Sources */, @@ -19553,6 +19579,7 @@ E13C072D2C2189B80087E404 /* ToolbarActionConfiguration.swift in Sources */, C24B31292E86B8470049134A /* GenericSelectableItemCellView.swift in Sources */, 43175DB826B87D2C00C41C31 /* AdsTelemetryHelper.swift in Sources */, + 767742A92F4F649500DEC06D /* PrivateLockAction.swift in Sources */, E58368AA287D632F0087A449 /* StoryProvider.swift in Sources */, 8A3EF7FF2A2FCFBB00796E3A /* ChangeToChinaSetting.swift in Sources */, 1D558A5A2BEE7D07001EF527 /* WindowSimpleTabsCoordinator.swift in Sources */, @@ -19570,6 +19597,7 @@ E13C07292C217D700087E404 /* NavigationBarState.swift in Sources */, 59A68FD5260B8D520F890F4A /* ReaderPanel.swift in Sources */, 0A93C8AA2C87070300BEA143 /* TrackingProtectionConnectionStatusView.swift in Sources */, + 76AFB6632F58B16C0064E6AC /* PrivateLockMiddleware.swift in Sources */, 39BD570E2E53F3ED00CA1317 /* MerinoFeedFetcher.swift in Sources */, C849E46126B9C39B00260F0B /* EnhancedTrackingProtectionVC.swift in Sources */, 8AA0A6632CAC40AA00AC7EB3 /* HomepageDiffableDataSource.swift in Sources */, diff --git a/firefox-ios/Client/Configuration/version.xcconfig b/firefox-ios/Client/Configuration/version.xcconfig index 5a7b338dfa910..711fd1774f848 100644 --- a/firefox-ios/Client/Configuration/version.xcconfig +++ b/firefox-ios/Client/Configuration/version.xcconfig @@ -1 +1 @@ -APP_VERSION = 149.2 \ No newline at end of file +APP_VERSION = 149.2 diff --git a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift index a0948edd8a2e4..db2bd8280318b 100644 --- a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift +++ b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift @@ -498,7 +498,9 @@ final class BrowserCoordinator: BaseCoordinator, } func didFinishSettings(from coordinator: SettingsCoordinator) { - router.dismiss(animated: true, completion: nil) + router.dismiss(animated: true, completion: { [weak browserViewController] in + browserViewController?.settingsControllerDidHide() + }) remove(child: coordinator) } @@ -944,7 +946,7 @@ final class BrowserCoordinator: BaseCoordinator, coordinator.showQRCode(delegate: delegate) } - func showTabTray(selectedPanel: TabTrayPanelType) { + func showTabTray(selectedPanel: TabTrayPanelType, animated: Bool) { guard !childCoordinators.contains(where: { $0 is TabTrayCoordinator }) else { return // flow is already handled } @@ -983,9 +985,12 @@ final class BrowserCoordinator: BaseCoordinator, if featureFlags.isFeatureEnabled(.tabTrayUIExperiments, checking: .buildOnly) && UIDevice.current.userInterfaceIdiom != .pad && selectedPanel != .syncedTabs { guard let tabTrayVC = tabTrayCoordinator.tabTrayViewController else { return } - present(navigationController, customTransition: tabTrayVC, style: modalPresentationStyle) + present(navigationController, + customTransition: tabTrayVC, + style: modalPresentationStyle, + animated: animated) } else { - present(navigationController) + present(navigationController, animated: animated) } guard browserViewController.isAppStoreReviewTriggerEnabled else { return } browserViewController.ratingPromptManager.showRatingPromptIfNeeded() @@ -994,12 +999,13 @@ final class BrowserCoordinator: BaseCoordinator, // This implementation of present is specifically for the animation on .tabTrayUIExperiments private func present(_ viewController: UIViewController, customTransition: UIViewControllerTransitioningDelegate, - style: UIModalPresentationStyle) { + style: UIModalPresentationStyle, + animated: Bool = true) { browserViewController.willNavigateAway(from: tabManager.selectedTab) if !UIAccessibility.isReduceMotionEnabled { router.present( viewController, - animated: true, + animated: animated, customTransition: customTransition, presentationStyle: style ) @@ -1008,9 +1014,9 @@ final class BrowserCoordinator: BaseCoordinator, } } - private func present(_ viewController: UIViewController) { + private func present(_ viewController: UIViewController, animated: Bool = true) { browserViewController.willNavigateAway(from: tabManager.selectedTab) - router.present(viewController) + router.present(viewController, animated: animated) } func showBackForwardList() { @@ -1253,6 +1259,15 @@ final class BrowserCoordinator: BaseCoordinator, // [FXIOS-10482] Initial bandaid for memory leaking during tab tray open/close. Needs further investigation. coordinator.dismissChildTabTrayPanels() remove(child: coordinator) + let panel = TabTrayPanelType.convert(from: tabManager.selectedTab) + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.didChangeTrayPresentation, + trayDisplayContext: .page, + trayPanelType: panel + ) + ) } // MARK: - WindowEventCoordinator diff --git a/firefox-ios/Client/Coordinators/Browser/BrowserNavigationHandler.swift b/firefox-ios/Client/Coordinators/Browser/BrowserNavigationHandler.swift index efef1d7c33eb6..e1a91aa01bb22 100644 --- a/firefox-ios/Client/Coordinators/Browser/BrowserNavigationHandler.swift +++ b/firefox-ios/Client/Coordinators/Browser/BrowserNavigationHandler.swift @@ -68,7 +68,7 @@ protocol BrowserNavigationHandler: AnyObject, QRCodeNavigationHandler { /// Shows the Tab Tray View Controller. @MainActor - func showTabTray(selectedPanel: TabTrayPanelType) + func showTabTray(selectedPanel: TabTrayPanelType, animated: Bool) /// Shows the Back Forward List View Controller. @MainActor diff --git a/firefox-ios/Client/FeatureFlags/NimbusFlaggableFeature.swift b/firefox-ios/Client/FeatureFlags/NimbusFlaggableFeature.swift index c398dae354f61..737032d42fc6e 100644 --- a/firefox-ios/Client/FeatureFlags/NimbusFlaggableFeature.swift +++ b/firefox-ios/Client/FeatureFlags/NimbusFlaggableFeature.swift @@ -32,6 +32,7 @@ enum NimbusFeatureFlagID: String, CaseIterable { case homepageStoriesScrollDirection case homepageStoryCategories case needsReloadRefactor + case privateTabsLock case shouldUseBrandRefreshConfiguration case shouldUseJapanConfiguration case microsurvey @@ -103,6 +104,7 @@ enum NimbusFeatureFlagID: String, CaseIterable { .needsReloadRefactor, .noInternetConnectionErrorPage, .otherErrorPages, + .privateTabsLock, .quickAnswers, .recentSearches, .relayIntegration, @@ -160,6 +162,8 @@ struct NimbusFlaggableFeature: HasNimbusSearchBar { return FlagKeys.SentFromFirefox case .startAtHome: return FlagKeys.StartAtHome + case .privateTabsLock: + return PrefsKeys.Settings.lockPrivateTabs // Cases where users do not have the option to manipulate a setting. Please add in alphabetical order. case .aiKillSwitch, .appearanceMenu, diff --git a/firefox-ios/Client/Frontend/Browser/BrowserViewController/Actions/PrivateLockAction.swift b/firefox-ios/Client/Frontend/Browser/BrowserViewController/Actions/PrivateLockAction.swift new file mode 100644 index 0000000000000..6a44d94b25d0f --- /dev/null +++ b/firefox-ios/Client/Frontend/Browser/BrowserViewController/Actions/PrivateLockAction.swift @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Redux + +enum PrivateLockActionType: ActionType { + case privateAuthRequested(String) + case didChangeTrayDisplayContext + case didChangeTrayPresentation + case didEnterBackground + case willEnterForeground + case didChangePrivateTabsLockSetting +} + +struct PrivateLockAction: Action { + let windowUUID: WindowUUID + let actionType: ActionType + let trayDisplayContext: BrowserViewControllerState.TrayDisplayContext? + let trayPanelType: TabTrayPanelType? + + init(windowUUID: WindowUUID, + actionType: ActionType, + trayDisplayContext: BrowserViewControllerState.TrayDisplayContext? = nil, + trayPanelType: TabTrayPanelType? = nil) { + self.windowUUID = windowUUID + self.actionType = actionType + self.trayDisplayContext = trayDisplayContext + self.trayPanelType = trayPanelType + } +} + +enum PrivateLockMiddlewareActionType: ActionType { + case didChangePrivateLockState + case didChangeTabTrayPanelType + case didChangeTrayDisplayContext +} + +struct PrivateLockMiddlewareAction: Action { + let windowUUID: WindowUUID + let actionType: ActionType + let privateLockState: PrivateLockDomainState? + let trayPanelType: TabTrayPanelType? + let trayDisplayContext: BrowserViewControllerState.TrayDisplayContext? + let privateLockEnabled: Bool? + + init(windowUUID: WindowUUID, + actionType: ActionType, + privatePanelLockState: PrivateLockDomainState? = nil, + trayPanelType: TabTrayPanelType? = nil, + trayDisplayContext: BrowserViewControllerState.TrayDisplayContext? = nil, + privateLockEnabled: Bool? = nil) { + self.windowUUID = windowUUID + self.actionType = actionType + self.privateLockState = privatePanelLockState + self.trayPanelType = trayPanelType + self.trayDisplayContext = trayDisplayContext + self.privateLockEnabled = privateLockEnabled + } +} diff --git a/firefox-ios/Client/Frontend/Browser/BrowserViewController/Middleware/PrivateLockMiddleware.swift b/firefox-ios/Client/Frontend/Browser/BrowserViewController/Middleware/PrivateLockMiddleware.swift new file mode 100644 index 0000000000000..40aba2a12c515 --- /dev/null +++ b/firefox-ios/Client/Frontend/Browser/BrowserViewController/Middleware/PrivateLockMiddleware.swift @@ -0,0 +1,176 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Redux +import Shared +import LocalAuthentication + +@MainActor +final class PrivateLockMiddleware: FeatureFlaggable { + private let prefs: Prefs + + init(profile: Profile = AppContainer.shared.resolve()) { + prefs = profile.prefs + } + + lazy var lockProvider: Middleware = { state, action in + if let action = action as? PrivateLockAction { + self.resolveTabPrivateLockActions(action: action, state: state) + } else if let action = action as? TabTrayAction { + self.resolveTabChange(action: action, state: state) + } + } + + private func resolveTabPrivateLockActions(action: PrivateLockAction, state: AppState) { + guard isPrivateLockFeatureEnabled() else { + unlock(windowUUID: action.windowUUID) + return + } + + switch action.actionType { + case PrivateLockActionType.privateAuthRequested(let reason): + let browserState = self.browserState(from: state, windowUUID: action.windowUUID) + let lockState = browserState?.privateLockState + guard lockState?.auth != .authenticating, lockState?.access == .locked else { return } + startPrivateTabsAuthFlow(reason: reason, windowUUID: action.windowUUID) + case PrivateLockActionType.didChangeTrayDisplayContext: + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: action.windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangeTrayDisplayContext, + trayDisplayContext: action.trayDisplayContext) + ) + case PrivateLockActionType.didChangeTrayPresentation: + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: action.windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangeTrayDisplayContext, + trayDisplayContext: action.trayDisplayContext) + ) + resolveTabTrayPanelTypeChange(panel: action.trayPanelType, + windowUUID: action.windowUUID, + state: state) + case PrivateLockActionType.didEnterBackground, PrivateLockActionType.willEnterForeground: + lock(triggeredByFailure: false, windowUUID: action.windowUUID) + case PrivateLockActionType.didChangePrivateTabsLockSetting: + let browserState = self.browserState(from: state, windowUUID: action.windowUUID) + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: action.windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangePrivateLockState, + privatePanelLockState: browserState?.privateLockState.withLastUnlocked(at: nil) + )) + default: + break + } + } + + private func resolveTabChange(action: TabTrayAction, state: AppState) { + switch action.actionType { + case TabTrayActionType.changePanel: + resolveTabTrayPanelTypeChange(panel: action.panelType, + windowUUID: action.windowUUID, + state: state) + default: + break + } + } + + private func resolveTabTrayPanelTypeChange(panel: TabTrayPanelType?, + windowUUID: WindowUUID, + state: AppState) { + guard let panelType = panel, + let state = browserState(from: state, windowUUID: windowUUID) + else { return } + + let privateLockEnabled = isPrivateLockFeatureEnabled() + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangeTabTrayPanelType, + trayPanelType: panel, + privateLockEnabled: privateLockEnabled + )) + + // Only trigger a relock when switching to the private panel, + // the feature is enabled, we are not already authenticating, + // and the relock timeout has elapsed + guard state.didBecomePrivateVisible(afterChangingPanelTo: panelType), + privateLockEnabled, + state.privateLockState.auth != .authenticating, + state.privateLockState.shouldRelockByTime + else { return } + + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangePrivateLockState, + privatePanelLockState: state.privateLockState.locked() + )) + } + + private func startPrivateTabsAuthFlow(reason: String, windowUUID: WindowUUID) { + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangePrivateLockState, + privatePanelLockState: PrivateLockDomainState(access: .locked, + auth: .authenticating, + lastUnlockedAt: nil) + )) + + let context = LAContext() + context.localizedCancelTitle = .PrivateLock.PrivateLockLAContextCancel + + var error: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + lock(triggeredByFailure: true, windowUUID: windowUUID) + return + } + + context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) { [weak self] success, _ in + guard let self else { return } + + ensureMainThread { + if success { + self.unlock(windowUUID: windowUUID) + } else { + self.lock(triggeredByFailure: true, windowUUID: windowUUID) + } + } + } + } + + private func lock(triggeredByFailure: Bool, windowUUID: WindowUUID) { + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangePrivateLockState, + privatePanelLockState: + PrivateLockDomainState(access: .locked, + auth: triggeredByFailure ? .failed : .idle, + lastUnlockedAt: nil) + )) + } + + private func unlock(windowUUID: WindowUUID) { + store.dispatch(PrivateLockMiddlewareAction( + windowUUID: windowUUID, + actionType: PrivateLockMiddlewareActionType.didChangePrivateLockState, + privatePanelLockState: PrivateLockDomainState(access: .unlocked, + auth: .idle, + lastUnlockedAt: Date()) + )) + } + + private func isPrivateLockFeatureEnabled() -> Bool { + featureFlags.isFeatureEnabled(.privateTabsLock, checking: .buildAndUser) + } + + private func browserState(from appState: AppState, windowUUID: WindowUUID) -> BrowserViewControllerState? { + appState.screenState( + BrowserViewControllerState.self, + for: .browserViewController, + window: windowUUID + ) + } +} diff --git a/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/BrowserViewControllerState.swift b/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/BrowserViewControllerState.swift index 29975c10c363c..e778b08455b6c 100644 --- a/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/BrowserViewControllerState.swift +++ b/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/BrowserViewControllerState.swift @@ -39,6 +39,11 @@ struct BrowserViewControllerState: ScreenState { case translationLanguagePicker(languages: [String]) } + enum TrayDisplayContext: Equatable { + case page + case tray + } + let windowUUID: WindowUUID var searchScreenState: SearchScreenState var toast: ToastType? @@ -52,6 +57,9 @@ struct BrowserViewControllerState: ScreenState { var frameContext: PasswordGeneratorFrameContext? var microsurveyState: MicrosurveyPromptState var navigationDestination: NavigationDestination? + var privateLockState: PrivateLockDomainState + var trayPanelType: TabTrayPanelType? + var trayDisplayContext = TrayDisplayContext.page init(appState: AppState, uuid: WindowUUID) { guard let bvcState = appState.screenState( @@ -75,7 +83,10 @@ struct BrowserViewControllerState: ScreenState { buttonTapped: bvcState.buttonTapped, frameContext: bvcState.frameContext, microsurveyState: bvcState.microsurveyState, - navigationDestination: bvcState.navigationDestination) + navigationDestination: bvcState.navigationDestination, + privateLockState: bvcState.privateLockState, + trayPanelType: bvcState.trayPanelType, + trayDisplayContext: bvcState.trayDisplayContext) } init(windowUUID: WindowUUID) { @@ -105,7 +116,11 @@ struct BrowserViewControllerState: ScreenState { buttonTapped: UIButton? = nil, frameContext: PasswordGeneratorFrameContext? = nil, microsurveyState: MicrosurveyPromptState, - navigationDestination: NavigationDestination? = nil + navigationDestination: NavigationDestination? = nil, + privateLockState: PrivateLockDomainState = + PrivateLockDomainState(access: .unlocked, auth: .idle, lastUnlockedAt: nil), + trayPanelType: TabTrayPanelType? = nil, + trayDisplayContext: TrayDisplayContext = TrayDisplayContext.page ) { self.searchScreenState = searchScreenState self.toast = toast @@ -120,6 +135,8 @@ struct BrowserViewControllerState: ScreenState { self.frameContext = frameContext self.microsurveyState = microsurveyState self.navigationDestination = navigationDestination + self.privateLockState = privateLockState + self.trayPanelType = trayPanelType } static let reducer: Reducer = { state, action in @@ -142,6 +159,8 @@ struct BrowserViewControllerState: ScreenState { return reduceStateForToolbarAction(action: action, state: state) } else if let action = action as? SummarizeAction { return reduceStateForSummarizeAction(action: action, state: state) + } else if let action = action as? PrivateLockMiddlewareAction { + return reduceStateForPrivateLockAction(action: action, state: state) } else { return BrowserViewControllerState( searchScreenState: state.searchScreenState, @@ -150,7 +169,10 @@ struct BrowserViewControllerState: ScreenState { shouldStartAtHome: false, browserViewType: state.browserViewType, microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), - navigationDestination: nil) + navigationDestination: nil, + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } } @@ -180,7 +202,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), - navigationDestination: action.navigationDestination + navigationDestination: action.navigationDestination, + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext ) default: return passthroughState(from: state, action: action) @@ -214,13 +239,50 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), - navigationDestination: NavigationDestination(.summarizer(config: action.summarizerConfig)) + navigationDestination: NavigationDestination(.summarizer(config: action.summarizerConfig)), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext ) default: return passthroughState(from: state, action: action) } } + @MainActor + static func reduceStateForPrivateLockAction( + action: PrivateLockMiddlewareAction, + state: BrowserViewControllerState + ) -> BrowserViewControllerState { + switch action.actionType { + case PrivateLockMiddlewareActionType.didChangePrivateLockState: + guard let privateLockState = action.privateLockState else { return state } + var newState = state + newState.privateLockState = privateLockState + return newState + case PrivateLockMiddlewareActionType.didChangeTabTrayPanelType: + guard let panelType = action.trayPanelType else { return state } + var newState = state + newState.trayPanelType = panelType + return newState + case PrivateLockMiddlewareActionType.didChangeTrayDisplayContext: + guard let trayDisplayContext = action.trayDisplayContext else { return state } + var newState = state + newState.trayDisplayContext = trayDisplayContext + return newState + default: + return state + } + } + + func didBecomePrivateVisible(afterChangingPanelTo panelType: TabTrayPanelType) -> Bool { + !isPrivateSurfaceVisible && panelType == .privateTabs + } + + private var isPrivateSurfaceVisible: Bool { + trayPanelType == .privateTabs + } + // MARK: - Toolbar Action /// Navigate to homepage zero search state, which is a scrim layer / dimming view, @@ -247,7 +309,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), - navigationDestination: NavigationDestination(.homepageZeroSearch) + navigationDestination: NavigationDestination(.homepageZeroSearch), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext ) default: return passthroughState(from: state, action: action) @@ -282,7 +347,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), - navigationDestination: NavigationDestination(.zeroSearch) + navigationDestination: NavigationDestination(.zeroSearch), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext ) } @@ -293,7 +361,11 @@ struct BrowserViewControllerState: ScreenState { searchScreenState: state.searchScreenState, windowUUID: state.windowUUID, browserViewType: state.browserViewType, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext + ) } @MainActor @@ -367,7 +439,11 @@ struct BrowserViewControllerState: ScreenState { toast: toastType, windowUUID: state.windowUUID, browserViewType: state.browserViewType, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext + ) } @MainActor @@ -379,7 +455,10 @@ struct BrowserViewControllerState: ScreenState { showOverlay: showOverlay, windowUUID: state.windowUUID, browserViewType: state.browserViewType, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -391,7 +470,11 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .home, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext + ) } @MainActor @@ -403,7 +486,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .newTab, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -415,7 +501,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .backForwardList, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -429,7 +518,10 @@ struct BrowserViewControllerState: ScreenState { browserViewType: state.browserViewType, displayView: .trackingProtectionDetails, buttonTapped: action.buttonTapped, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -442,7 +534,10 @@ struct BrowserViewControllerState: ScreenState { browserViewType: state.browserViewType, displayView: .menu, buttonTapped: action.buttonTapped, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -454,7 +549,11 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .tabsLongPressActions, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext + ) } @MainActor @@ -467,7 +566,10 @@ struct BrowserViewControllerState: ScreenState { browserViewType: state.browserViewType, displayView: .reloadLongPressAction, buttonTapped: action.buttonTapped, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -480,7 +582,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .locationViewLongPressAction, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -492,7 +597,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .back, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -504,7 +612,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .forward, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -516,7 +627,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .tabTray, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -528,7 +642,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .reload, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -540,7 +657,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .reloadNoCache, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -552,7 +672,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, navigateTo: .stopLoading, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -565,7 +688,10 @@ struct BrowserViewControllerState: ScreenState { browserViewType: state.browserViewType, displayView: .share, buttonTapped: action.buttonTapped, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -577,7 +703,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .readerMode, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -589,7 +718,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .newTabLongPressActions, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -601,7 +733,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .readerModeLongPressAction, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -612,7 +747,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .dataClearance, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -624,7 +762,10 @@ struct BrowserViewControllerState: ScreenState { browserViewType: state.browserViewType, displayView: .passwordGenerator, frameContext: action.frameContext, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -635,7 +776,10 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, browserViewType: state.browserViewType, displayView: .summarizer(config: action.summarizerConfig), - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } @MainActor @@ -664,7 +808,10 @@ struct BrowserViewControllerState: ScreenState { searchScreenState: state.searchScreenState, windowUUID: state.windowUUID, browserViewType: state.browserViewType, - microsurveyState: microsurveyState + microsurveyState: microsurveyState, + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext ) } @@ -674,7 +821,10 @@ struct BrowserViewControllerState: ScreenState { searchScreenState: state.searchScreenState, windowUUID: state.windowUUID, browserViewType: state.browserViewType, - microsurveyState: microsurveyState + microsurveyState: microsurveyState, + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext ) } @@ -696,7 +846,11 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, reloadWebView: true, browserViewType: browserViewType, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext + ) } @MainActor @@ -709,6 +863,9 @@ struct BrowserViewControllerState: ScreenState { windowUUID: state.windowUUID, shouldStartAtHome: action.shouldStartAtHome ?? false, browserViewType: state.browserViewType, - microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action)) + microsurveyState: MicrosurveyPromptState.reducer(state.microsurveyState, action), + privateLockState: state.privateLockState, + trayPanelType: state.trayPanelType, + trayDisplayContext: state.trayDisplayContext) } } diff --git a/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/PrivateLockDomainState.swift b/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/PrivateLockDomainState.swift new file mode 100644 index 0000000000000..761edcbeffa4c --- /dev/null +++ b/firefox-ios/Client/Frontend/Browser/BrowserViewController/State/PrivateLockDomainState.swift @@ -0,0 +1,44 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Foundation + +struct PrivateLockDomainState: Equatable { + enum PrivateAccessState: Equatable { + case locked + case unlocked + } + + enum PrivateAuthState: Equatable { + case idle + case authenticating + case failed + } + + var access: PrivateAccessState = .locked + var auth: PrivateAuthState = .idle + var lastUnlockedAt: Date? + let relockInterval: TimeInterval = 120 + + var shouldRelockByTime: Bool { + guard let lastUnlockedAt else { return true } + return Date().timeIntervalSince(lastUnlockedAt) > relockInterval + } + + func copy(access: PrivateAccessState? = nil, + auth: PrivateAuthState? = nil, + lastUnlockedAt: Date? = nil) -> PrivateLockDomainState { + PrivateLockDomainState(access: access ?? self.access, + auth: auth ?? self.auth, + lastUnlockedAt: lastUnlockedAt ?? self.lastUnlockedAt) + } + + func locked() -> PrivateLockDomainState { + copy(access: .locked) + } + + func withLastUnlocked(at: Date?) -> PrivateLockDomainState { + PrivateLockDomainState(access: access, auth: auth, lastUnlockedAt: at) + } +} diff --git a/firefox-ios/Client/Frontend/Browser/BrowserViewController/Views/BrowserViewController.swift b/firefox-ios/Client/Frontend/Browser/BrowserViewController/Views/BrowserViewController.swift index 625188df1e6a3..b759461eecbb9 100644 --- a/firefox-ios/Client/Frontend/Browser/BrowserViewController/Views/BrowserViewController.swift +++ b/firefox-ios/Client/Frontend/Browser/BrowserViewController/Views/BrowserViewController.swift @@ -947,6 +947,21 @@ class BrowserViewController: UIViewController, // individual TabManager instances for each BVC, so we perform these here instead. tabManager.preserveTabs() logTelemetryForAppDidEnterBackground() + + showPrivacyOverlayIfNeeded() + } + + private func showPrivacyOverlayIfNeeded(checkActualState: Bool = false) { + guard let state = browserViewControllerState else { + return + } + let featureEnabled = featureFlags.isFeatureEnabled(.privateTabsLock, checking: .buildAndUser) + if state.trayDisplayContext == .page && state.trayPanelType == .privateTabs && featureEnabled { + focusOnTabSegment(animated: false) + if checkActualState { + privateLockWillEnterForeground() + } + } } /// Remove KVO observers on terminate to prevent crashes during force-close. @@ -967,12 +982,32 @@ class BrowserViewController: UIViewController, guard canShowPrivacyWindow else { return } privacyWindowHelper.showWindow(windowScene: currentWindowScene, withThemedColor: currentTheme().colors.layer3) + + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.didEnterBackground + ) + ) } func sceneDidActivateNotification() { privacyWindowHelper.removeWindow() } + func sceneWillEnterForegroundNotification() { + privateLockWillEnterForeground() + } + + private func privateLockWillEnterForeground() { + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.willEnterForeground + ) + ) + } + func appWillResignActiveNotification() { // Dismiss any popovers that might be visible displayedPopoverController?.dismiss(animated: false) { @@ -1177,6 +1212,15 @@ class BrowserViewController: UIViewController, } } + private func startPrivateAuthFlow() { + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.privateAuthRequested(.PrivateLock.PrivateLockLAContextReason) + ) + ) + } + // MARK: - Lifecycle override func viewDidLoad() { @@ -1322,6 +1366,8 @@ class BrowserViewController: UIViewController, sceneDidEnterBackgroundNotification(windowScene: windowScene) case UIScene.didActivateNotification: sceneDidActivateNotification() + case UIScene.willEnterForegroundNotification: + sceneWillEnterForegroundNotification() case UIAccessibility.announcementDidFinishNotification: didFinishAnnouncement(announcementText: announcementText) case UIAccessibility.reduceTransparencyStatusDidChangeNotification: @@ -1361,6 +1407,7 @@ class BrowserViewController: UIViewController, UIApplication.didEnterBackgroundNotification, UIApplication.willTerminateNotification, UIScene.didEnterBackgroundNotification, + UIScene.willEnterForegroundNotification, UIScene.didActivateNotification, UIAccessibility.announcementDidFinishNotification, UIAccessibility.reduceTransparencyStatusDidChangeNotification, @@ -2294,6 +2341,14 @@ class BrowserViewController: UIViewController, topTabsViewController?.refreshTabs() } setupMicrosurvey() + + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.didChangeTrayDisplayContext, + trayDisplayContext: .page + ) + ) } func updateInContentHomePanel(_ url: URL?, focusUrlBar: Bool = false) { @@ -2940,7 +2995,7 @@ class BrowserViewController: UIViewController, case .summarizer(let config): navigationHandler?.showSummarizePanel(.shakeGesture, config: config) case .tabTray(let panelType): - navigationHandler?.showTabTray(selectedPanel: panelType) + navigationHandler?.showTabTray(selectedPanel: panelType, animated: true) case .homepageZeroSearch: store.dispatch( GeneralBrowserAction( @@ -3312,10 +3367,10 @@ class BrowserViewController: UIViewController, presentSheetWith(viewModel: viewModel, on: self, from: view) } - func focusOnTabSegment() { + func focusOnTabSegment(animated: Bool = true) { let isPrivateTab = tabManager.selectedTab?.isPrivate ?? false let segmentToFocus = isPrivateTab ? TabTrayPanelType.privateTabs : TabTrayPanelType.tabs - showTabTray(focusedSegment: segmentToFocus) + showTabTray(focusedSegment: segmentToFocus, animated: animated) } /// When the trait collection changes the top taps display might have to change @@ -3399,12 +3454,21 @@ class BrowserViewController: UIViewController, } func showTabTray(withFocusOnUnselectedTab tabToFocus: Tab? = nil, - focusedSegment: TabTrayPanelType? = nil) { + focusedSegment: TabTrayPanelType? = nil, + animated: Bool = true) { updateFindInPageVisibility(isVisible: false) let isPrivateTab = tabManager.selectedTab?.isPrivate ?? false let selectedSegment: TabTrayPanelType = focusedSegment ?? (isPrivateTab ? .privateTabs : .tabs) - navigationHandler?.showTabTray(selectedPanel: selectedSegment) + navigationHandler?.showTabTray(selectedPanel: selectedSegment, animated: animated) + + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.didChangeTrayDisplayContext, + trayDisplayContext: .tray + ) + ) } func submitSearchText(_ text: String, forTab tab: Tab) { @@ -4698,6 +4762,10 @@ extension BrowserViewController: SearchViewControllerDelegate { self.present(navController, animated: true, completion: nil) } + func settingsControllerDidHide() { + showPrivacyOverlayIfNeeded(checkActualState: true) + } + func updateForDefaultSearchEngineDidChange() { // Update search icon when the search engine changes let action = ToolbarAction(windowUUID: windowUUID, actionType: ToolbarActionType.searchEngineDidChange) @@ -4780,6 +4848,8 @@ extension BrowserViewController: SearchViewControllerDelegate { } extension BrowserViewController: TabManagerDelegate { + // FXIOS-15079 break this function down and remove swiftlint ignore function body length + // swiftlint:disable:next function_body_length func tabManager(_ tabManager: TabManager, didSelectedTabChange selectedTab: Tab, previousTab: Tab?, isRestoring: Bool) { // Failing to have a non-nil webView by this point will cause the toolbar scrolling behaviour to regress, // back/forward buttons never to become enabled, etc. on tab restore after launch. [FXIOS-9785, FXIOS-9781] @@ -4928,6 +4998,15 @@ extension BrowserViewController: TabManagerDelegate { if needsReload { selectedTab.reloadPage() } + + let panel = TabTrayPanelType.convert(from: selectedTab) + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.didChangeTrayPresentation, + trayPanelType: panel + ) + ) } // TODO: FXIOS-14347 This function will be removed when toolbarTranslucencyRefactor is on for everyone diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayPanelType.swift b/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayPanelType.swift index 39ed233cadd69..759bd159335ba 100644 --- a/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayPanelType.swift +++ b/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayPanelType.swift @@ -64,4 +64,10 @@ enum TabTrayPanelType: Int, CaseIterable { } return panelType } + + @MainActor + static func convert(from tab: Tab?) -> TabTrayPanelType { + let isPrivateTab = tab?.isPrivate ?? false + return isPrivateTab ? TabTrayPanelType.privateTabs : TabTrayPanelType.tabs + } } diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayState.swift b/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayState.swift index e50e218cb0c00..2d74975c5e1f5 100644 --- a/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayState.swift +++ b/firefox-ios/Client/Frontend/Browser/Tabs/State/TabTrayState.swift @@ -22,6 +22,7 @@ struct TabTrayState: ScreenState, Equatable { var windowUUID: WindowUUID var showCloseConfirmation: Bool var enableDeleteTabsButton: Bool? + var hideToolbar: Bool? var navigationTitle: String { return selectedPanel.navTitle @@ -45,6 +46,9 @@ struct TabTrayState: ScreenState, Equatable { return } + let browserState = appState.screenState(BrowserViewControllerState.self, for: .browserViewController, window: uuid) + let privateLockState = browserState?.privateLockState + let hideToolbar = privateLockState?.access == .locked && browserState?.trayPanelType == .privateTabs self.init(windowUUID: panelState.windowUUID, isPrivateMode: panelState.isPrivateMode, selectedPanel: panelState.selectedPanel, @@ -54,7 +58,8 @@ struct TabTrayState: ScreenState, Equatable { shouldDismiss: panelState.shouldDismiss, toastType: panelState.toastType, showCloseConfirmation: panelState.showCloseConfirmation, - enableDeleteTabsButton: panelState.enableDeleteTabsButton) + enableDeleteTabsButton: panelState.enableDeleteTabsButton, + hideToolbar: hideToolbar) } init(windowUUID: WindowUUID) { @@ -64,7 +69,8 @@ struct TabTrayState: ScreenState, Equatable { normalTabsCount: "0", privateTabsCount: "0", hasSyncableAccount: false, - toastType: nil) + toastType: nil, + hideToolbar: nil) } init(windowUUID: WindowUUID, panelType: TabTrayPanelType) { @@ -73,7 +79,8 @@ struct TabTrayState: ScreenState, Equatable { selectedPanel: panelType, normalTabsCount: "0", privateTabsCount: "0", - hasSyncableAccount: false) + hasSyncableAccount: false, + hideToolbar: nil) } init(windowUUID: WindowUUID, @@ -85,7 +92,8 @@ struct TabTrayState: ScreenState, Equatable { shouldDismiss: Bool = false, toastType: ToastType? = nil, showCloseConfirmation: Bool = false, - enableDeleteTabsButton: Bool? = nil) { + enableDeleteTabsButton: Bool? = nil, + hideToolbar: Bool? = nil) { self.windowUUID = windowUUID self.isPrivateMode = isPrivateMode self.selectedPanel = selectedPanel @@ -96,6 +104,7 @@ struct TabTrayState: ScreenState, Equatable { self.toastType = toastType self.showCloseConfirmation = showCloseConfirmation self.enableDeleteTabsButton = enableDeleteTabsButton + self.hideToolbar = hideToolbar } static let reducer: Reducer = { state, action in diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/State/TabsPanelState.swift b/firefox-ios/Client/Frontend/Browser/Tabs/State/TabsPanelState.swift index 03f19d22d982b..36791d4e05fcc 100644 --- a/firefox-ios/Client/Frontend/Browser/Tabs/State/TabsPanelState.swift +++ b/firefox-ios/Client/Frontend/Browser/Tabs/State/TabsPanelState.swift @@ -18,6 +18,7 @@ struct TabsPanelState: ScreenState, Equatable { var scrollState: ScrollState? var didTapAddTab: Bool var urlRequest: URLRequest? + var privateLockState: PrivateLockDomainState? var isPrivateTabsEmpty: Bool { guard isPrivateMode else { return true } @@ -34,12 +35,14 @@ struct TabsPanelState: ScreenState, Equatable { return } + let browserState = appState.screenState(BrowserViewControllerState.self, for: .browserViewController, window: uuid) self.init(windowUUID: panelState.windowUUID, isPrivateMode: panelState.isPrivateMode, tabs: panelState.tabs, scrollState: panelState.scrollState, didTapAddTab: panelState.didTapAddTab, - urlRequest: panelState.urlRequest) + urlRequest: panelState.urlRequest, + privateLockState: browserState?.privateLockState) } init(windowUUID: WindowUUID, isPrivateMode: Bool = false) { @@ -59,13 +62,15 @@ struct TabsPanelState: ScreenState, Equatable { toastType: ToastType? = nil, scrollState: ScrollState? = nil, didTapAddTab: Bool = false, - urlRequest: URLRequest? = nil) { + urlRequest: URLRequest? = nil, + privateLockState: PrivateLockDomainState? = nil) { self.isPrivateMode = isPrivateMode self.tabs = tabs self.windowUUID = windowUUID self.scrollState = scrollState self.didTapAddTab = didTapAddTab self.urlRequest = urlRequest + self.privateLockState = privateLockState } static let reducer: Reducer = { state, action in @@ -115,7 +120,6 @@ struct TabsPanelState: ScreenState, Equatable { isPrivateMode: state.isPrivateMode, tabs: state.tabs, scrollState: scrollModel) - default: return defaultState(from: state) } diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/Views/FirefoxGradientBackgroundView.swift b/firefox-ios/Client/Frontend/Browser/Tabs/Views/FirefoxGradientBackgroundView.swift new file mode 100644 index 0000000000000..fe721aa5f7d06 --- /dev/null +++ b/firefox-ios/Client/Frontend/Browser/Tabs/Views/FirefoxGradientBackgroundView.swift @@ -0,0 +1,93 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import UIKit + +final class FirefoxGradientBackgroundView: UIView { + + private let gradientLayer = CAGradientLayer() + + private let glow1 = CALayer() + private let glow2 = CALayer() + private let glow3 = CALayer() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + + gradientLayer.colors = [ + UIColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1).cgColor, // orange + UIColor(red: 0.83, green: 0.25, blue: 1.0, alpha: 1).cgColor, // purple + UIColor(red: 0.02, green: 0.1, blue: 0.3, alpha: 1).cgColor // dark blue + ] + + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + + layer.addSublayer(gradientLayer) + + configureGlow(glow1, + color: UIColor(red: 1, green: 0.3, blue: 0.35, alpha: 0.6)) + + configureGlow(glow2, + color: UIColor(red: 0.8, green: 0.2, blue: 1.0, alpha: 0.6)) + + configureGlow(glow3, + color: UIColor(red: 0.35, green: 0.0, blue: 1.0, alpha: 0.6)) + } + + private func configureGlow(_ layerGlow: CALayer, color: UIColor) { + + layerGlow.backgroundColor = color.cgColor + layerGlow.opacity = 0.8 + + layerGlow.shadowColor = color.cgColor + layerGlow.shadowRadius = 120 + layerGlow.shadowOpacity = 1 + layerGlow.shadowOffset = .zero + + layer.addSublayer(layerGlow) + } + + override func layoutSubviews() { + super.layoutSubviews() + + gradientLayer.frame = bounds + + let size1: CGFloat = bounds.width * 0.9 + glow1.frame = CGRect( + x: bounds.width * 0.65, + y: bounds.height * 0.1, + width: size1, + height: size1 + ) + glow1.cornerRadius = size1 / 2 + + let size2: CGFloat = bounds.width * 0.8 + glow2.frame = CGRect( + x: bounds.width * -0.2, + y: bounds.height * 0.4, + width: size2, + height: size2 + ) + glow2.cornerRadius = size2 / 2 + + let size3: CGFloat = bounds.width * 0.9 + glow3.frame = CGRect( + x: bounds.width * 0.2, + y: bounds.height * 0.75, + width: size3, + height: size3 + ) + glow3.cornerRadius = size3 / 2 + } +} diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/Views/PrivateTabsLockOverlayView.swift b/firefox-ios/Client/Frontend/Browser/Tabs/Views/PrivateTabsLockOverlayView.swift new file mode 100644 index 0000000000000..354cccbb3988f --- /dev/null +++ b/firefox-ios/Client/Frontend/Browser/Tabs/Views/PrivateTabsLockOverlayView.swift @@ -0,0 +1,155 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import UIKit + +final class PrivateTabsLockOverlayView: UIView { + enum Mode: Equatable { + case prompt + case authenticating + case failed + } + + var onRetryTapped: (() -> Void)? + + private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let spinner = UIActivityIndicatorView(style: .large) + private let stack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func render(access: PrivateLockDomainState.PrivateAccessState, auth: PrivateLockDomainState.PrivateAuthState) { + switch access { + case .unlocked: + isHidden = true + apply(mode: .prompt) + + case .locked: + isHidden = false + switch auth { + case .idle: + apply(mode: .prompt) + case .authenticating: + apply(mode: .authenticating) + case .failed: + apply(mode: .failed) + } + } + } + + func renderHidden() { + isHidden = true + apply(mode: .prompt) + } + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + + let background = FirefoxGradientBackgroundView() + + addSubview(background) + background.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + background.topAnchor.constraint(equalTo: topAnchor), + background.bottomAnchor.constraint(equalTo: bottomAnchor), + background.leadingAnchor.constraint(equalTo: leadingAnchor), + background.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + + titleLabel.font = .preferredFont(forTextStyle: .title2) + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + titleLabel.textColor = .white + titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + titleLabel.text = "Private Tabs Locked" + + subtitleLabel.font = .preferredFont(forTextStyle: .body) + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + subtitleLabel.textColor = .white.withAlphaComponent(0.85) + subtitleLabel.text = "Unlock with Face ID to view private tabs." + + spinner.hidesWhenStopped = true + + retryButton.setTitle("Try Again", for: .normal) + retryButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) + retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + + stack.axis = .vertical + stack.alignment = .center + stack.distribution = .fill + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(subtitleLabel) + stack.addArrangedSubview(spinner) + stack.addArrangedSubview(retryButton) + + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + apply(mode: .prompt) + } + + private let retryButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + + var config = UIButton.Configuration.filled() + config.title = "Try Again" + config.baseBackgroundColor = UIColor.systemBlue + config.baseForegroundColor = .white + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 28, bottom: 14, trailing: 28) + + button.configuration = config + button.titleLabel?.font = UIFont.systemFont(ofSize: 20, weight: .semibold) + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.18 + button.layer.shadowRadius = 10 + button.layer.shadowOffset = CGSize(width: 0, height: 4) + + return button + }() + + func apply(mode: Mode) { + switch mode { + case .prompt: + spinner.stopAnimating() + retryButton.isHidden = true + subtitleLabel.text = "Unlock with Face ID to view private tabs" + + case .authenticating: + spinner.startAnimating() + retryButton.isHidden = true + subtitleLabel.text = "Authenticating…" + + case .failed: + spinner.stopAnimating() + retryButton.isHidden = false + subtitleLabel.text = "Face ID failed. Try again." + } + } + + @objc private func retryTapped() { + onRetryTapped?() + } +} diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabDisplayPanelViewController.swift b/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabDisplayPanelViewController.swift index cefbc2fb90b74..b81de9baa92f1 100644 --- a/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabDisplayPanelViewController.swift +++ b/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabDisplayPanelViewController.swift @@ -29,6 +29,7 @@ final class TabDisplayPanelViewController: UIViewController, private let windowUUID: WindowUUID var currentWindowUUID: UUID? { windowUUID } private var viewHasAppeared = false + private var privateLockAuthTask: Task? private var isTabTrayUIExperimentsEnabled: Bool { return featureFlags.isFeatureEnabled(.tabTrayUIExperiments, checking: .buildOnly) @@ -131,6 +132,12 @@ final class TabDisplayPanelViewController: UIViewController, shouldShowFadeView() } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + privateLockAuthTask?.cancel() + privateLockAuthTask = nil + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() gradientLayer.frame = fadeView.bounds @@ -164,6 +171,14 @@ final class TabDisplayPanelViewController: UIViewController, backgroundPrivacyOverlay.isHidden = true setupEmptyView() setupFadeView() + + view.addSubview(privateLockOverlay) + NSLayoutConstraint.activate([ + privateLockOverlay.topAnchor.constraint(equalTo: view.topAnchor), + privateLockOverlay.leadingAnchor.constraint(equalTo: view.leadingAnchor), + privateLockOverlay.trailingAnchor.constraint(equalTo: view.trailingAnchor), + privateLockOverlay.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) } private func setupEmptyView() { @@ -195,6 +210,22 @@ final class TabDisplayPanelViewController: UIViewController, view.removeFromSuperview() } + private lazy var privateLockOverlay: PrivateTabsLockOverlayView = { + let overlay = PrivateTabsLockOverlayView() + overlay.isHidden = true + overlay.onRetryTapped = { [weak self] in self?.startPrivateAuthFlow() } + return overlay + }() + + private func startPrivateAuthFlow() { + store.dispatch( + PrivateLockAction( + windowUUID: windowUUID, + actionType: PrivateLockActionType.privateAuthRequested(.PrivateLock.PrivateLockLAContextReason) + ) + ) + } + // MARK: - Themeable func applyTheme() { @@ -344,6 +375,7 @@ final class TabDisplayPanelViewController: UIViewController, if panelType == .privateTabs, tabsState.isPrivateMode { // Only adjust the empty view if we are in private mode shouldShowEmptyView(tabsState.isPrivateTabsEmpty) + applyPrivateLockUI(tabsState.privateLockState) } shouldShowFadeView() @@ -352,6 +384,37 @@ final class TabDisplayPanelViewController: UIViewController, } } + private func applyPrivateLockUI(_ lock: PrivateLockDomainState?) { + guard panelType == .privateTabs, let lock else { + privateLockOverlay.renderHidden() + privateLockAuthTask?.cancel() + privateLockAuthTask = nil + return + } + + privateLockOverlay.render( + access: lock.access, + auth: lock.auth + ) + + guard lock.access == .locked else { + privateLockAuthTask?.cancel() + privateLockAuthTask = nil + return + } + + guard lock.auth == .idle else { return } + + privateLockAuthTask?.cancel() + privateLockAuthTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 sec delay for better ux + guard !Task.isCancelled else { return } + guard let self else { return } + guard self.isViewLoaded, self.view.window != nil else { return } + self.startPrivateAuthFlow() + } + } + // MARK: - EmptyPrivateTabsViewDelegate func didTapLearnMore(urlRequest: URLRequest) { diff --git a/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabTrayViewController.swift b/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabTrayViewController.swift index 45db02e02f3dd..a6ecfa5779915 100644 --- a/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabTrayViewController.swift +++ b/firefox-ios/Client/Frontend/Browser/Tabs/Views/TabTrayViewController.swift @@ -420,6 +420,8 @@ final class TabTrayViewController: UIViewController, if !themeAnimator.isAnimating && swipeFromIndex == nil { applyTheme() } + + navigationController?.setToolbarHidden(state.hideToolbar ?? false, animated: false) } func updateTabCountImage(count: String) { diff --git a/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift b/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift index 5d8948dabfc08..79d78126433b9 100644 --- a/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/Main/AppSettingsTableViewController.swift @@ -439,6 +439,23 @@ class AppSettingsTableViewController: SettingsTableViewController, store.dispatch(action) } ) + + if featureFlags.isFeatureEnabled(.privateTabsLock, checking: .buildOnly) { + privacySettings.append( + BoolSetting( + prefs: profile.prefs, + theme: themeManager.getCurrentTheme(for: windowUUID), + prefKey: PrefsKeys.Settings.lockPrivateTabs, + defaultValue: false, + titleText: .AppSettingsLockPrivateTabs, + statusText: .AppSettingsLockPrivateTabsDescription + ) { _ in + let action = PrivateLockAction(windowUUID: self.windowUUID, + actionType: PrivateLockActionType.didChangePrivateTabsLockSetting) + store.dispatch(action) + } + ) + } } privacySettings.append(ContentBlockerSetting(settings: self, settingsDelegate: parentCoordinator)) diff --git a/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift b/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift index e35d4b6751b9c..33df6b0dd93a6 100644 --- a/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift @@ -150,6 +150,13 @@ final class FeatureFlagsDebugViewController: SettingsTableViewController, Featur ) { [weak self] _ in self?.reloadView() }, + FeatureFlagsBoolSetting( + with: .privateTabsLock, + titleText: format(string: "Private Tabs Lock"), + statusText: format(string: "Toggle Private Tabs Lock") + ) { [weak self] _ in + self?.reloadView() + }, FeatureFlagsBoolSetting( with: .relayIntegration, titleText: format(string: "Relay Email Masks"), diff --git a/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift b/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift index c77ca5a5d1d1f..1224ebf5c02e9 100644 --- a/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift +++ b/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift @@ -76,6 +76,9 @@ final class NimbusFeatureFlagLayer: Sendable { case .needsReloadRefactor: return checkNeedsReloadRefactorFeature(from: nimbus) + case .privateTabsLock: + return checkPrivateTabsLockFeature(from: nimbus) + case .shouldUseBrandRefreshConfiguration: return checkShouldUseBrandRefreshConfigurationFeature(from: nimbus) @@ -547,6 +550,10 @@ final class NimbusFeatureFlagLayer: Sendable { return nimbus.features.aiKillSwitchFeature.value().enabled } + private func checkPrivateTabsLockFeature(from nimbus: FxNimbus) -> Bool { + return nimbus.features.privateTabsLockFeature.value().enabled + } + private func checkBookmarksSearchFeature(from nimbus: FxNimbus) -> Bool { return nimbus.features.bookmarksSearchFeature.value().enabled } diff --git a/firefox-ios/Client/Redux/GlobalState/AppState.swift b/firefox-ios/Client/Redux/GlobalState/AppState.swift index aaf3f5dae1436..1b0ed3ecfdf6f 100644 --- a/firefox-ios/Client/Redux/GlobalState/AppState.swift +++ b/firefox-ios/Client/Redux/GlobalState/AppState.swift @@ -87,6 +87,7 @@ let middlewares = [ SummarizerMiddleware().summarizerProvider, TermsOfUseMiddleware().termsOfUseProvider, TranslationsMiddleware().translationsProvider, + PrivateLockMiddleware().lockProvider, TranslationSettingsMiddleware().translationSettingsProvider ] diff --git a/firefox-ios/Shared/Strings.swift b/firefox-ios/Shared/Strings.swift index 9b3c5fc85e1be..a72fa26d101f0 100644 --- a/firefox-ios/Shared/Strings.swift +++ b/firefox-ios/Shared/Strings.swift @@ -7892,6 +7892,16 @@ extension String { tableName: nil, value: nil, comment: "About settings section title") + public static let AppSettingsLockPrivateTabs = MZLocalizedString( + key: "Lock Private Tabs", + tableName: nil, + value: "Lock Private Tabs", + comment: "Lock Private Tabs title") + public static let AppSettingsLockPrivateTabsDescription = MZLocalizedString( + key: "Lock Private Tabs Section Description", + tableName: nil, + value: "Use Biometrics or Passcode to see Private Tabs", + comment: "Lock Private Tabs description") } // MARK: - Clearables @@ -8419,4 +8429,21 @@ extension String { } } +// MARK: - Private Lock Feature +extension String { + public struct PrivateLock { + public static let PrivateLockLAContextCancel = MZLocalizedString( + key: "Cancel", + tableName: nil, + value: nil, + comment: "Label for Cancel button") + + public static let PrivateLockLAContextReason = MZLocalizedString( + key: "PrivateLock.Reason", + tableName: nil, + value: "Unlock your private tabs", + comment: "Label for Biometry authorization request") + } +} + // swiftlint:enable line_length diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/BrowserViewController/BrowserViewControllerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/BrowserViewController/BrowserViewControllerTests.swift index c378cace293dc..6d34dd0211cc2 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/BrowserViewController/BrowserViewControllerTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/BrowserViewController/BrowserViewControllerTests.swift @@ -139,7 +139,7 @@ class BrowserViewControllerTests: XCTestCase, StoreTestUtility { let actionCalled = try XCTUnwrap(mockStore.dispatchedActions[2] as? GeneralBrowserAction) let actionType = try XCTUnwrap(actionCalled.actionType as? GeneralBrowserActionType) - XCTAssertEqual(mockStore.dispatchedActions.count, 5) + XCTAssertEqual(mockStore.dispatchedActions.count, 6) XCTAssertEqual(actionType, GeneralBrowserActionType.didSelectedTabChangeToHomepage) } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/BrowserCoordinatorTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/BrowserCoordinatorTests.swift index f67ffd1d339b3..d21544c1ae86e 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/BrowserCoordinatorTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/BrowserCoordinatorTests.swift @@ -400,7 +400,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil func testShowTabTray() throws { setupNimbusTabTrayUIExperimentTesting(isEnabled: false) let subject = createSubject() - subject.showTabTray(selectedPanel: .tabs) + subject.showTabTray(selectedPanel: .tabs, animated: true) XCTAssertEqual(subject.childCoordinators.count, 1) XCTAssertNotNil(subject.childCoordinators[0] as? TabTrayCoordinator) @@ -413,7 +413,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil setupNimbusTabTrayUIExperimentTesting(isEnabled: true) let subject = createSubject() subject.browserViewController = browserViewController - subject.showTabTray(selectedPanel: .tabs) + subject.showTabTray(selectedPanel: .tabs, animated: true) XCTAssertEqual(subject.childCoordinators.count, 1) XCTAssertNotNil(subject.childCoordinators[0] as? TabTrayCoordinator) @@ -425,7 +425,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil func testShowTabTray_withPrivateTabs_withoutExperiment_showDefaultPresentation() throws { setupNimbusTabTrayUIExperimentTesting(isEnabled: false) let subject = createSubject() - subject.showTabTray(selectedPanel: .privateTabs) + subject.showTabTray(selectedPanel: .privateTabs, animated: true) XCTAssertEqual(subject.childCoordinators.count, 1) XCTAssertNotNil(subject.childCoordinators[0] as? TabTrayCoordinator) @@ -438,7 +438,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil setupNimbusTabTrayUIExperimentTesting(isEnabled: true) let subject = createSubject() subject.browserViewController = browserViewController - subject.showTabTray(selectedPanel: .privateTabs) + subject.showTabTray(selectedPanel: .privateTabs, animated: true) XCTAssertEqual(subject.childCoordinators.count, 1) XCTAssertNotNil(subject.childCoordinators[0] as? TabTrayCoordinator) @@ -451,7 +451,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil setupNimbusTabTrayUIExperimentTesting(isEnabled: false) let subject = createSubject() subject.browserViewController = browserViewController - subject.showTabTray(selectedPanel: .syncedTabs) + subject.showTabTray(selectedPanel: .syncedTabs, animated: true) XCTAssertEqual(subject.childCoordinators.count, 1) XCTAssertNotNil(subject.childCoordinators[0] as? TabTrayCoordinator) @@ -464,7 +464,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil setupNimbusTabTrayUIExperimentTesting(isEnabled: true) let subject = createSubject() subject.browserViewController = browserViewController - subject.showTabTray(selectedPanel: .syncedTabs) + subject.showTabTray(selectedPanel: .syncedTabs, animated: true) XCTAssertEqual(subject.childCoordinators.count, 1) XCTAssertNotNil(subject.childCoordinators[0] as? TabTrayCoordinator) @@ -475,7 +475,7 @@ final class BrowserCoordinatorTests: XCTestCase, FeatureFlaggable, StoreTestUtil func testDismissTabTray_removesChild() throws { let subject = createSubject() - subject.showTabTray(selectedPanel: .tabs) + subject.showTabTray(selectedPanel: .tabs, animated: true) guard let tabTrayCoordinator = subject.childCoordinators[0] as? TabTrayCoordinator else { XCTFail("Tab tray coordinator was expected to be resolved") return diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/Mocks/MockBrowserCoordinator.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/Mocks/MockBrowserCoordinator.swift index 1334f83a88943..58cdd8e406861 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/Mocks/MockBrowserCoordinator.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Coordinators/Mocks/MockBrowserCoordinator.swift @@ -97,7 +97,7 @@ class MockBrowserCoordinator: BrowserNavigationHandler, showEnhancedTrackingProtectionCalled += 1 } - func showTabTray(selectedPanel: TabTrayPanelType) { + func showTabTray(selectedPanel: TabTrayPanelType, animated: Bool) { showTabTrayCalled += 1 } diff --git a/firefox-ios/nimbus-features/privateTabsLockFeature.yaml b/firefox-ios/nimbus-features/privateTabsLockFeature.yaml new file mode 100644 index 0000000000000..155b90f55ae65 --- /dev/null +++ b/firefox-ios/nimbus-features/privateTabsLockFeature.yaml @@ -0,0 +1,18 @@ +# The configuration for the privateTabsLockFeature feature +features: + private-tabs-lock-feature: + description: > + Protect private tabs with authentication + variables: + enabled: + description: > + Whether or not this feature is enabled + type: Boolean + default: false + defaults: + - channel: beta + value: + enabled: false + - channel: developer + value: + enabled: true \ No newline at end of file diff --git a/firefox-ios/nimbus.fml.yaml b/firefox-ios/nimbus.fml.yaml index 9d58ab402975e..870b4d4347e49 100644 --- a/firefox-ios/nimbus.fml.yaml +++ b/firefox-ios/nimbus.fml.yaml @@ -38,6 +38,7 @@ include: - nimbus-features/newAddressBarMenu.yaml - nimbus-features/newAppearanceMenu.yaml - nimbus-features/onboardingFrameworkFeature.yaml + - nimbus-features/privateTabsLockFeature.yaml - nimbus-features/recentSearchesFeature.yaml - nimbus-features/relayIntegrationFeature.yaml - nimbus-features/searchFeature.yaml