PM-10280 - Implement Autofill Setup Completion Screen (#981)

This commit is contained in:
Phil Cappelli 2024-10-01 12:39:56 -04:00 committed by GitHub
parent 1b45969eeb
commit c35520f9db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 291 additions and 54 deletions

View File

@ -10,6 +10,9 @@ enum ExternalLinksConstants {
/// A link to Bitwarden's organizations information webpage.
static let aboutOrganizations = URL(string: "https://bitwarden.com/help/about-organizations")!
/// A deep link to the Bitwarden app.
static let appDeepLink = URL(string: "bitwarden://")!
/// A link to the app review page within the app store.
static let appReview = URL(string: "https://itunes.apple.com/us/app/id1137397744?action=write-review")

View File

@ -9,6 +9,11 @@ extension String {
return range(of: regex, options: .regularExpression) != nil
}
/// A Boolean value indicating whether the string represents the "otpauth" scheme.
var isOtpAuthScheme: Bool {
self == "otpauth"
}
/// `true` if prefixed with `steam://` and followed by a base 32 string.
var isSteamUri: Bool {
guard let keyIndexOffset = steamURIKeyIndexOffset else {

View File

@ -64,7 +64,7 @@ public struct OTPAuthModel: Equatable, Hashable, Sendable {
///
init?(otpAuthKey: String) {
guard let urlComponents = URLComponents(string: otpAuthKey),
urlComponents.scheme == "otpauth",
urlComponents.scheme?.isOtpAuthScheme == true,
let queryItems = urlComponents.queryItems,
let secret = queryItems.first(where: { $0.name == "secret" })?.value else {
return nil

View File

@ -64,6 +64,18 @@ class AppModuleTests: BitwardenTestCase {
XCTAssertTrue(navigationController.viewControllers[0] is UIHostingController<DebugMenuView>)
}
/// `makeExtensionSetupCoordinator` builds the extensions setup coordinator.
@MainActor
func test_makeExtensionSetupCoordinator() {
let navigationController = UINavigationController()
let coordinator = subject.makeExtensionSetupCoordinator(
stackNavigator: navigationController
)
coordinator.navigate(to: .extensionActivation(type: .autofillExtension))
XCTAssertEqual(navigationController.viewControllers.count, 1)
XCTAssertTrue(navigationController.viewControllers[0] is UIHostingController<ExtensionActivationView>)
}
/// `makeSendCoordinator()` builds the send coordinator.
@MainActor
func test_makeSendCoordinator() {

View File

@ -102,6 +102,8 @@ public class AppProcessor {
/// - Parameter url: The deep link URL to handle.
///
public func openUrl(_ url: URL) async {
guard url.scheme?.isOtpAuthScheme == true else { return }
guard let otpAuthModel = OTPAuthModel(otpAuthKey: url.absoluteString) else {
coordinator?.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
return

View File

@ -402,10 +402,20 @@ class AppProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(coordinator.events, [.setAuthCompletionRoute(.tab(.vault(.vaultItemSelection(model))))])
}
/// `openUrl(_:)` handles receiving an non OTP deep link and silently returns with a no-op.
@MainActor
func test_openUrl_nonOtpKey_failSilently() async throws {
try await subject.openUrl(XCTUnwrap(URL(string: "bitwarden://")))
XCTAssertEqual(coordinator.alertShown, [])
XCTAssertEqual(coordinator.routes, [])
}
/// `openUrl(_:)` handles receiving an OTP deep link if the URL isn't an OTP key.
@MainActor
func test_openUrl_otpKey_invalid() async throws {
try await subject.openUrl(XCTUnwrap(URL(string: "https://google.com")))
let otpKey: String = .otpAuthUriKeyNoSecret
try await subject.openUrl(XCTUnwrap(URL(string: otpKey)))
XCTAssertEqual(coordinator.alertShown, [.defaultAlert(title: Localizations.anErrorHasOccurred)])
XCTAssertEqual(coordinator.routes, [])

View File

@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "autofill-illustration.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "autofill-illustration-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -101,7 +101,7 @@
"EditItem" = "Edit item";
"EnableAutomaticSyncing" = "Allow automatic syncing";
"EnterEmailForHint" = "Enter your account email address to receive your master password hint.";
"ExntesionReenable" = "Reactivate app extension";
"ReactivateAppExtension" = "Reactivate app extension";
"ExtensionAlmostDone" = "Almost done!";
"ExtensionEnable" = "Activate app extension";
"ExtensionInSafari" = "In Safari, find Bitwarden using the share icon (hint: scroll to the right on the bottom row of the menu).";
@ -984,5 +984,9 @@
"Confirm" = "Confirm";
"ErrorConnectingWithTheDuoServiceUseADifferentTwoStepLoginMethodOrContactDuoForAssistance" = "Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance.";
"ThePreAuthUrlsCouldNotBeLoadedToStartTheAccountCreation" = "The Pre Auth Urls could not be loaded to start the account creation.";
"ContinueToBitwarden" = "Continue to Bitwarden";
"BackToSettings" = "Back to settings";
"YoureAllSet" = "You're all set!";
"AutoFillActivatedDescriptionLong" = "You can now use autofill to log into apps and websites using your saved passwords. Now, you can explore everything else Bitwarden has to offer.";
"GetStarted" = "Get started";
"Dismiss" = "Dismiss";

View File

@ -0,0 +1,8 @@
// MARK: - ExtensionActivationEffect
/// The enumeration of possible effects performed by the `ExtensionActivationProcessor`.
///
enum ExtensionActivationEffect: Equatable {
/// The extension activation view appeared.
case appeared
}

View File

@ -5,35 +5,63 @@
class ExtensionActivationProcessor: StateProcessor<
ExtensionActivationState,
ExtensionActivationAction,
Void
ExtensionActivationEffect
> {
// MARK: Types
typealias Services = HasConfigService
// MARK: Private Properties
/// A delegate used to communicate with the app extension.
private weak var appExtensionDelegate: AppExtensionDelegate?
/// The services used by the processor.
private let services: Services
// MARK: Initialization
/// Initialize a `ExtensionActivationProcessor`.
///
/// - Parameters:
/// - appExtensionDelegate: A delegate used to communicate with the app extension.
/// - services: The services used by the processor.
/// - state: The initial state of the processor.
///
init(
appExtensionDelegate: AppExtensionDelegate?,
services: Services,
state: ExtensionActivationState
) {
self.appExtensionDelegate = appExtensionDelegate
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: ExtensionActivationEffect) async {
switch effect {
case .appeared:
await loadFeatureFlag()
}
}
override func receive(_ action: ExtensionActivationAction) {
switch action {
case .cancelTapped:
appExtensionDelegate?.didCancel()
}
}
// MARK: Private
/// Sets the feature flag value to be used.
///
private func loadFeatureFlag() async {
state.isNativeCreateAccountFeatureFlagEnabled = await services.configService.getFeatureFlag(
.nativeCreateAccountFlow,
isPreAuth: true
)
}
}

View File

@ -2,10 +2,13 @@ import XCTest
@testable import BitwardenShared
// MARK: - ExtensionActivationProcessorTests
class ExtensionActivationProcessorTests: BitwardenTestCase {
// MARK: Properties
var appExtensionDelegate: MockAppExtensionDelegate!
var configService: MockConfigService!
var subject: ExtensionActivationProcessor!
// MARK: Setup & Teardown
@ -14,9 +17,10 @@ class ExtensionActivationProcessorTests: BitwardenTestCase {
super.setUp()
appExtensionDelegate = MockAppExtensionDelegate()
configService = MockConfigService()
subject = ExtensionActivationProcessor(
appExtensionDelegate: appExtensionDelegate,
services: ServiceContainer.withMocks(configService: configService),
state: ExtensionActivationState(extensionType: .autofillExtension)
)
}
@ -25,6 +29,7 @@ class ExtensionActivationProcessorTests: BitwardenTestCase {
super.tearDown()
appExtensionDelegate = nil
configService = nil
subject = nil
}
@ -37,4 +42,34 @@ class ExtensionActivationProcessorTests: BitwardenTestCase {
XCTAssertTrue(appExtensionDelegate.didCancelCalled)
}
/// `perform(.appeared)` with feature flag for .nativeCreateAccountFlow set to true
@MainActor
func test_perform_appeared_loadFeatureFlag_true() async {
configService.featureFlagsBool[.nativeCreateAccountFlow] = true
subject.state.isNativeCreateAccountFeatureFlagEnabled = false
await subject.perform(.appeared)
XCTAssertTrue(subject.state.isNativeCreateAccountFeatureFlagEnabled)
}
/// `perform(.appeared)` with feature flag for .nativeCreateAccountFlow set to false
@MainActor
func test_perform_appeared_loadsFeatureFlag_false() async {
configService.featureFlagsBool[.nativeCreateAccountFlow] = false
subject.state.isNativeCreateAccountFeatureFlagEnabled = true
await subject.perform(.appeared)
XCTAssertFalse(subject.state.isNativeCreateAccountFeatureFlagEnabled)
}
/// `perform(.appeared)` with feature flag defaulting to false
@MainActor
func test_perform_appeared_loadsFeatureFlag_nil() async {
configService.featureFlagsBool[.nativeCreateAccountFlow] = nil
subject.state.isNativeCreateAccountFeatureFlagEnabled = true
await subject.perform(.appeared)
XCTAssertFalse(subject.state.isNativeCreateAccountFeatureFlagEnabled)
}
}

View File

@ -7,4 +7,42 @@ struct ExtensionActivationState: Equatable, Sendable {
/// The type of extension to show the activation view for.
var extensionType: ExtensionActivationType
/// Whether the native create account feature flag is on.
var isNativeCreateAccountFeatureFlagEnabled = false
/// The message text in the view.
var message: String {
switch extensionType {
case .appExtension:
Localizations.extensionSetup +
.newLine +
Localizations.extensionSetup2
case .autofillExtension:
Localizations.autofillSetup +
.newLine +
Localizations.autofillSetup2
}
}
/// The title for the navigation bar.
var navigationBarTitle: String {
guard isNativeCreateAccountFeatureFlagEnabled else { return "" }
return extensionType == .autofillExtension ? Localizations.accountSetup : ""
}
/// Whether or not to show the new or legacy view.
var showLegacyView: Bool {
!isNativeCreateAccountFeatureFlagEnabled || extensionType == .appExtension
}
/// The title text in the view.
var title: String {
switch extensionType {
case .appExtension:
Localizations.extensionActivated
case .autofillExtension:
Localizations.autofillActivated
}
}
}

View File

@ -8,34 +8,83 @@ struct ExtensionActivationView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<ExtensionActivationState, ExtensionActivationAction, Void>
@ObservedObject var store: Store<
ExtensionActivationState,
ExtensionActivationAction,
ExtensionActivationEffect
>
/// The title text in the view.
var title: String {
switch store.state.extensionType {
case .appExtension:
Localizations.extensionActivated
case .autofillExtension:
Localizations.autofillActivated
/// An action that opens URLs.
@Environment(\.openURL) private var openURL
// MARK: View
var body: some View {
Group {
if store.state.showLegacyView {
legacyContent
} else {
content
}
}
.scrollView()
.navigationTitle(store.state.navigationBarTitle)
.navigationBarTitleDisplayMode(.inline)
.task {
await store.perform(.appeared)
}
}
/// The message text in the view.
var message: String {
switch store.state.extensionType {
case .appExtension:
Localizations.extensionSetup +
.newLine +
Localizations.extensionSetup2
case .autofillExtension:
Localizations.autofillSetup +
.newLine +
Localizations.autofillSetup2
// MARK: Private Views
/// The main content of the view.
@ViewBuilder private var content: some View {
VStack(spacing: 0) {
PageHeaderView(
image: Asset.Images.autofillIllustration,
title: Localizations.youreAllSet,
message: Localizations.autoFillActivatedDescriptionLong
)
Button(Localizations.continueToBitwarden) {
openURL(ExternalLinksConstants.appDeepLink)
}
.buttonStyle(.primary())
.padding(.top, 40)
Button(Localizations.backToSettings) {
store.send(.cancelTapped)
}
.buttonStyle(.transparent)
.padding(.top, 12)
}
}
/// The legacy view for this screen kept intact to support both versions.
@ViewBuilder private var legacyContent: some View {
VStack(spacing: 64) {
VStack(spacing: 20) {
Text(store.state.title)
.foregroundStyle(Asset.Colors.textPrimary.swiftUIColor)
.styleGuide(.title3)
Text(store.state.message)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
.styleGuide(.body)
}
.multilineTextAlignment(.center)
image
}
.toolbar {
cancelToolbarItem {
store.send(.cancelTapped)
}
}
}
/// The image to display in the view.
@ViewBuilder var image: some View {
@ViewBuilder private var image: some View {
switch store.state.extensionType {
case .appExtension:
Image(decorative: Asset.Images.bwLogo)
@ -53,42 +102,19 @@ struct ExtensionActivationView: View {
.foregroundStyle(.green)
}
}
// MARK: View
var body: some View {
VStack(spacing: 64) {
VStack(spacing: 20) {
Text(title)
.foregroundStyle(Asset.Colors.textPrimary.swiftUIColor)
.styleGuide(.title3)
Text(message)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
.styleGuide(.body)
}
.multilineTextAlignment(.center)
image
}
.scrollView()
.toolbar {
cancelToolbarItem {
store.send(.cancelTapped)
}
}
}
}
// MARK: - Previews
#if DEBUG
#Preview("Autofill Extension") {
NavigationView {
ExtensionActivationView(
store: Store(
processor: StateProcessor(
state: ExtensionActivationState(
extensionType: .autofillExtension
extensionType: .autofillExtension,
isNativeCreateAccountFeatureFlagEnabled: true
)
)
)
@ -109,3 +135,4 @@ struct ExtensionActivationView: View {
)
}
}
#endif

View File

@ -3,10 +3,16 @@ import XCTest
@testable import BitwardenShared
// MARK: - ExtensionActivationViewTests
class ExtensionActivationViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<ExtensionActivationState, ExtensionActivationAction, Void>!
var processor: MockProcessor<
ExtensionActivationState,
ExtensionActivationAction,
ExtensionActivationEffect
>!
var subject: ExtensionActivationView!
// MARK: Setup & Teardown
@ -37,6 +43,15 @@ class ExtensionActivationViewTests: BitwardenTestCase {
XCTAssertEqual(processor.dispatchedActions.last, .cancelTapped)
}
/// Tapping the back to settings dispatches the `.cancelTapped` action.
@MainActor
func test_backToSettingsButton_tap() throws {
processor.state.isNativeCreateAccountFeatureFlagEnabled = true
let button = try subject.inspect().find(button: Localizations.backToSettings)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .cancelTapped)
}
// MARK: Snapshots
/// The autofill extension activation view renders correctly.
@ -56,4 +71,14 @@ class ExtensionActivationViewTests: BitwardenTestCase {
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5]
)
}
/// The app extension activation view renders correctly when the `native-create-account-flow` ff is on.
@MainActor
func test_snapshot_extensionActivationView_autoFillExtension_featureFlagEnabled() {
processor.state.isNativeCreateAccountFeatureFlagEnabled = true
assertSnapshots(
of: subject.navStackWrapped,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5]
)
}
}

View File

@ -17,9 +17,9 @@ class ExtensionSetupCoordinatorTests: BitwardenTestCase {
super.setUp()
stackNavigator = MockStackNavigator()
subject = ExtensionSetupCoordinator(
appExtensionDelegate: MockAppExtensionDelegate(),
services: ServiceContainer.withMocks(),
stackNavigator: stackNavigator
)
}

View File

@ -3,6 +3,15 @@
/// A coordinator that manages navigation in the vault tab.
///
final class ExtensionSetupCoordinator: Coordinator, HasStackNavigator {
// MARK: Types
typealias Services = HasConfigService
// MARK: Private Properties
/// The services used by this coordinator.
private let services: Services
// MARK: Private Properties
/// A delegate used to communicate with the app extension.
@ -19,13 +28,16 @@ final class ExtensionSetupCoordinator: Coordinator, HasStackNavigator {
///
/// - Parameters:
/// - appExtensionDelegate: A delegate used to communicate with the app extension.
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
init(
appExtensionDelegate: AppExtensionDelegate?,
services: Services,
stackNavigator: StackNavigator
) {
self.appExtensionDelegate = appExtensionDelegate
self.services = services
self.stackNavigator = stackNavigator
}
@ -47,6 +59,7 @@ final class ExtensionSetupCoordinator: Coordinator, HasStackNavigator {
private func showExtensionActivation(extensionType: ExtensionActivationType) {
let processor = ExtensionActivationProcessor(
appExtensionDelegate: appExtensionDelegate,
services: services,
state: ExtensionActivationState(extensionType: extensionType)
)
let view = ExtensionActivationView(store: Store(processor: processor))

View File

@ -23,6 +23,7 @@ extension DefaultAppModule: ExtensionSetupModule {
) -> AnyCoordinator<ExtensionSetupRoute, Void> {
ExtensionSetupCoordinator(
appExtensionDelegate: appExtensionDelegate,
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}

View File

@ -82,7 +82,7 @@ struct AppExtensionView: View {
private var activateButton: some View {
Button(
store.state.extensionEnabled ?
Localizations.exntesionReenable :
Localizations.reactivateAppExtension :
Localizations.extensionEnable
) {
store.send(.activateButtonTapped)

View File

@ -9,6 +9,7 @@ extension String {
static let otpAuthUriKeyPartial = "otpauth://totp/Example:user@bitwarden.com?secret=JBSWY3DPEHPK3PXP"
// swiftlint:disable:next line_length
static let otpAuthUriKeySHA512 = "otpauth://totp/Example:user@bitwarden.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512"
static let otpAuthUriKeyNoSecret = "otpauth://totp/Example:user@bitwarden.com?"
static let steamUriKeyIdentifier = "JBSWY3DPEHPK3PXP"
static let steamUriKey = "steam://\(steamUriKeyIdentifier)"
}