Protect private tabs with biometric authentication via tab tray#32411
Protect private tabs with biometric authentication via tab tray#32411DarkSatyr wants to merge 31 commits intomozilla-mobile:mainfrom
Conversation
|
HI there! thank you so much for this change! It is an exciting new feature. This is an actual feature addition, so we are going to need some input from design and product. It means that feedback will take a little bit longer. Thank you for your patience! |
Thanks! Happy to wait for feedback from design and product. |
|
Hi @DarkSatyr! Product and Design are PUMPED about this feature! A few notes:
Let me know if none of that makes sense! I will keep an eye on this PR for comments or we can chat in Matrix https://chat.mozilla.org/#/room/#fx-ios:mozilla.org. I will provide my feedback on the code tomorrow! |
|
@Cramsden |
…and left state mutation for state
30708d4 to
ed9a933
Compare
|
@Cramsden output.mp4 |
Cramsden
left a comment
There was a problem hiding this comment.
Finished my first pass! Let me know if any comments are confusing! Happy to have any follow up discussion.
Could we add some tests for the new PrivateLockMiddleware? It is not super easy to add tests for BVC but any functionality we could test would be great!
| store.dispatch( | ||
| PrivateLockAction( | ||
| windowUUID: windowUUID, | ||
| actionType: PrivateLockActionType.setTrayDisplayContextAndPanelType, | ||
| trayDisplayContext: .page, | ||
| trayPanelType: panel | ||
| ) | ||
| ) |
There was a problem hiding this comment.
It is not clear to me from the naming what the action is actually doing here. Is there a more intuitive name we could use? Here are the action naming conventions if helpful! https://github.com/mozilla-mobile/firefox-ios/wiki/Redux-Guidelines---FAQs#tips
| router.dismiss(animated: true, completion: nil) | ||
| router.dismiss(animated: true, completion: { [weak browserViewController] in | ||
| browserViewController?.settingsControllerDidHide() | ||
| }) |
There was a problem hiding this comment.
What is this change needed for?
There was a problem hiding this comment.
it's just a hook to check actual state on possible settings change in order to show tray and lock on it
| case requestAuth(String) | ||
| case setPrivateContext | ||
| case setTrayDisplayContext | ||
| case setTrayDisplayContextAndPanelType | ||
| case didEnterBackground | ||
| case willEnterForeground | ||
| case lockPrivateTabsSettingsDidChange |
There was a problem hiding this comment.
I think we should work on these action names to align a bit more with the action naming recommendations https://github.com/mozilla-mobile/firefox-ios/wiki/Redux-Guidelines---FAQs#tips
| case setPrivateLockState | ||
| case changedTabTrayPanelType | ||
| case setTrayDisplayContext |
There was a problem hiding this comment.
I think these action names could use a little tweaking too.
|
|
||
| private func resolveTabPrivateLockActions(action: PrivateLockAction, state: AppState) { | ||
| guard isPrivateLockFeatureEnabled() else { | ||
| Self.unlock(windowUUID: action.windowUUID) |
There was a problem hiding this comment.
nit: we should not need self here
There was a problem hiding this comment.
do you mean Self.? or just self. in line 34?
unlock(windowUUID: is a static func
There was a problem hiding this comment.
Oh this is my bad I missed that this was Self not self. Any reason that lock and unlock need to be static?
There was a problem hiding this comment.
it's been used in result closure of context.evaluatePolicy, static helps to avoid capturing of "self"
There was a problem hiding this comment.
Static makes unit tests harder to make so I would advise against that pattern. Also, we would need unit tests for PrivateLockMiddleware which I don't think this PR has right now
There was a problem hiding this comment.
changed static with instance methods
| Task { @MainActor in | ||
| if success { | ||
| Self.unlock(windowUUID: windowUUID) | ||
| } else { | ||
| Self.lock(triggeredByFailure: true, windowUUID: windowUUID) | ||
| } |
There was a problem hiding this comment.
since neither of these functions are async and we are in a completion handler could we use ensureMainThread here instead of creating a standalone task?
| final class PrivateTabsLockFeatureGate: FeatureFlaggable { | ||
| private let prefs: Prefs | ||
| init(prefs: Prefs) { | ||
| self.prefs = prefs | ||
| } | ||
|
|
||
| var isEnabled: Bool { | ||
| let shouldLock = prefs.boolForKey(PrefsKeys.Settings.lockPrivateTabs) ?? false | ||
| let featureEnabled = featureFlags.isFeatureEnabled(.privateTabsLock, checking: .buildOnly) | ||
| return shouldLock && featureEnabled | ||
| } |
There was a problem hiding this comment.
I don't think we need this class. We should be able to handle features that are user and build configured using .buildAndUser. A good example is firefoxSuggestFeature
There was a problem hiding this comment.
good point, removed, and made use of . buildAndUser
| enum PrivateAccessState: Equatable { | ||
| case locked | ||
| case unlocked | ||
| } | ||
|
|
||
| enum PrivateAuthState: Equatable { | ||
| case idle | ||
| case authenticating | ||
| case failed | ||
| } | ||
|
|
||
| enum TrayDisplayContext: Equatable { | ||
| case page | ||
| case tray | ||
| } | ||
|
|
||
| struct PrivateLockDomainState: Equatable { | ||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
I think that these should live in their own class: PrivateLockDomainState
There was a problem hiding this comment.
good point, moved to separate file
| @MainActor | ||
| static func reduceStateForPrivateLockAction( | ||
| action: PrivateLockMiddlewareAction, | ||
| state: BrowserViewControllerState | ||
| ) -> BrowserViewControllerState { | ||
| switch action.actionType { | ||
| case PrivateLockMiddlewareActionType.setPrivateLockState: | ||
| guard let privateLockState = action.privateLockState else { return state } | ||
| var newState = state | ||
| newState.privateLockState = privateLockState | ||
| return newState | ||
| case PrivateLockMiddlewareActionType.changedTabTrayPanelType: | ||
| guard let panelType = action.trayPanelType else { return state } | ||
| var newState = state | ||
| newState.trayPanelType = panelType | ||
| return newState | ||
| case PrivateLockMiddlewareActionType.setTrayDisplayContext: | ||
| guard let trayDisplayContext = action.trayDisplayContext else { return state } | ||
| var newState = state | ||
| newState.trayDisplayContext = trayDisplayContext | ||
| return newState | ||
| default: | ||
| return state | ||
| } |
There was a problem hiding this comment.
If we move out PrivateLockDomainState to its own struct it can have its own reducer to handle these state updates
There was a problem hiding this comment.
I see what you mean, although in the current reducer this case is mostly just assigning the already-produced privateLockState back into BrowserViewControllerState.
The tray-related actions still feel browser-level to me, so I’d prefer to keep this reducer here for now unless you’d like me to do a broader refactor of the private lock state transitions
|
|
||
| navigationController?.setToolbarHidden(state.hideToolbar ?? false, animated: false) |
…ivateLockActionType.didChangeTrayPresentation, suppressed lint error for tabManager(_ tabManager:..) delegate method and added comment to fix it later, made use of ensureMainThread instead of Tasl { @mainactor
|
This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏 |
lmarceau
left a comment
There was a problem hiding this comment.
Thanks for the contribution! This is my first pass at reviewing the PR.
Before we can move forward any further, we’ll need some designs, as there are a few files we can’t properly review right now. I’ve left comments on them with some notes so we can come back to them later.
Also, please be aware of our guidelines for contributing. Since this PR is quite large, there’s a chance we may ask you to reopen it as smaller, incremental changes. We’re big fans of incremental PRs since they’re easier to review thoroughly.
Note that although my colleague already made great strides reviewing the work here, this PR may still sit in review for a while as we wait for feedback from the design team. Feel free to ask for an update at any time, but I just wanted to set expectations that this process may take some time.
Also, please make sure you’ve run Swiftlint locally and that you’re using the required version. I noticed a few Swiftlint warnings that will likely appear when the PR runs through Bitrise.
| CODE_SIGN_ENTITLEMENTS = ""; | ||
| COPY_PHASE_STRIP = NO; | ||
| DEFINES_MODULE = YES; | ||
| "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 5R87WU4VHL; |
There was a problem hiding this comment.
We shouldn't commit development team changes
| UIDevice.current.userInterfaceIdiom != .pad && selectedPanel != .syncedTabs { | ||
| guard let tabTrayVC = tabTrayCoordinator.tabTrayViewController else { return } | ||
| present(navigationController, customTransition: tabTrayVC, style: modalPresentationStyle) | ||
| present(navigationController, |
There was a problem hiding this comment.
Is there a case where animated is false now?
There was a problem hiding this comment.
showPrivacyOverlayIfNeeded in bvc calls with animated false on appDidEnterBackgroundNotification event
|
|
||
| private func resolveTabPrivateLockActions(action: PrivateLockAction, state: AppState) { | ||
| guard isPrivateLockFeatureEnabled() else { | ||
| Self.unlock(windowUUID: action.windowUUID) |
There was a problem hiding this comment.
Static makes unit tests harder to make so I would advise against that pattern. Also, we would need unit tests for PrivateLockMiddleware which I don't think this PR has right now
| )) | ||
|
|
||
| let context = LAContext() | ||
| context.localizedCancelTitle = "Cancel" |
There was a problem hiding this comment.
This will need to be localized under the Strings file
There was a problem hiding this comment.
moved to Strings, please check
| } | ||
|
|
||
| private func showPrivacyOverlayIfNeeded(checkActualState: Bool = false) { | ||
| if let state = browserViewControllerState { |
There was a problem hiding this comment.
Could use a guard here to reduce indentation levels, would make this easier to read
| } | ||
| } | ||
|
|
||
| @objc private func retryTapped() { onRetryTapped?() } |
There was a problem hiding this comment.
| @objc private func retryTapped() { onRetryTapped?() } | |
| @objc private func retryTapped() { | |
| onRetryTapped?() | |
| } |
|
|
||
| spinner.hidesWhenStopped = true | ||
|
|
||
| retryButton.setTitle("Try Again", for: .normal) |
| store.dispatch( | ||
| PrivateLockAction( | ||
| windowUUID: windowUUID, | ||
| actionType: PrivateLockActionType.privateAuthRequested("Unlock your private tabs") |
| // Only adjust the empty view if we are in private mode | ||
| shouldShowEmptyView(tabsState.isPrivateTabsEmpty) | ||
| } | ||
| if panelType == .privateTabs { |
There was a problem hiding this comment.
We shouldn't check for the tabsState.isPrivateMode as well?
| titleText: "Lock Private Tabs", | ||
| statusText: "Use Biometrics or Passcode to see Private Tabs" |
|
@lmarceau thanks for guiding me through the process, |
|
@Cramsden @lmarceau Another possible solution would be to detect transitions coming from quick actions and long-press-to-open-private links, and in those cases open the private tab tray instead. That would preserve the same behavior and keep a single entry point for locked private tabs. By the way, I checked Safari Quick actions flow and they are using tray to auth as a single entry point (solution #2). The only thing that they focus on a page after auth in a tray. Just to summarise: Approach 2: I don't think that it will be hard to do but still it's not done. Good thing that it's going to be a single entry point, no problems with animations from page to tray. Let me know what you think proto_output.mp4 |
|
This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏 |
|
This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏 |
|
@DarkSatyr, Thank you so much for all of this work. This work and your most recent prototype raised a lot of good questions about what we actually want this feature to look like, which has been really helpful. This is a feature we do want to add, so we want to make sure we do it right. The work here is great, but because there are so many different entry points into a private tab session, we want to make sure we are not missing anything. Even when Laurie and I were talking through this PR a few days ago, we identified 5+ entry points for creating a new private tab. We would like to take a step back and put together a proper spec for this feature, including designs and an architecture proposal. If you are interested, we would love to keep working with you on it. At the moment, the PR is too large for us to confidently review and merge, so we are going to close it for now. We plan to follow up with a contributor-OK epic that breaks the work into smaller incremental pieces that can be reviewed and merged more easily. If that is something you are still interested in, someone from our team will follow up with you soon. |
|
@Cramsden |
|
This PR has been automatically marked as stale. Please leave any comment to keep this PR opened. It will be closed automatically if no further update occurs in the next 7 days. Thank you for your contributions! |
|
This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏 |
|
I'll close this PR for now as was discussed |

Summary
This PR introduces a lock flow for Private Tabs using biometric authentication.
When the feature is enabled, private tabs are protected behind an authentication step before they become visible.
Behavior
This keeps the lock flow centralized in the tab tray rather than introducing a separate page overlay.
Implementation details
The implementation integrates with the existing Redux-style architecture used in Firefox iOS:
PrivateLockDomainStateto represent authentication and access stateUI
A lock overlay is shown in the private tab tray until authentication succeeds.
Notes
This implementation intentionally keeps the authentication entry point in the tab tray to avoid duplicating lock logic on private page surfaces.
I’d be happy to iterate on this if adjustments are needed to better fit the existing architecture.
Demo
output.mp4