diff --git a/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift b/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift index 3d9e409f06667..ca5b7f6a94368 100644 --- a/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift +++ b/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift @@ -25,6 +25,8 @@ public struct StandardImageIdentifiers { // Icon size 20x20 public struct Medium { + public static let adBlockerCheckmark = "adBlockerCheckmarkMedium" + public static let adBlockerCross = "adBlockerCrossMedium" public static let arrowClockwise = "arrowClockwiseMedium" public static let bookmarkBadgeFillBlue50 = "bookmarkBadgeFillMediumBlue50" public static let cross = "crossMedium" diff --git a/BrowserKit/Sources/MenuKit/MenuSiteBadge.swift b/BrowserKit/Sources/MenuKit/MenuSiteBadge.swift new file mode 100644 index 0000000000000..cdcf6a2dab34a --- /dev/null +++ b/BrowserKit/Sources/MenuKit/MenuSiteBadge.swift @@ -0,0 +1,121 @@ +// 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 +import Common + +final class MenuSiteBadge: UIView, ThemeApplicable { + private struct UX { + static let cornerRadius: CGFloat = 12 + static let borderWidth: CGFloat = 1 + static let horizontalPadding: CGFloat = 10 + static let verticalPadding: CGFloat = 6 + static let contentSpacing: CGFloat = 4 + static let iconSize: CGFloat = 16 + static let chevronSize: CGFloat = 20 + } + + var tapHandler: (() -> Void)? + private let mainMenuHelper: MainMenuInterface + + private lazy var stack: UIStackView = .build { [weak self] stack in + guard let self else { return } + stack.isLayoutMarginsRelativeArrangement = true + stack.layoutMargins = UIEdgeInsets(top: UX.verticalPadding, + left: UX.horizontalPadding, + bottom: UX.verticalPadding, + right: UX.horizontalPadding) + stack.distribution = .fill + stack.axis = .horizontal + stack.clipsToBounds = true + stack.spacing = UX.contentSpacing + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.tapped)) + stack.isUserInteractionEnabled = true + stack.addGestureRecognizer(tapGesture) + } + + private var label: UILabel = .build { label in + label.font = FXFontStyles.Regular.footnote.scaledFont() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.adjustsFontForContentSizeCategory = true + label.accessibilityTraits = .button + } + + private var icon: UIImageView = .build { imageView in + imageView.contentMode = .scaleAspectFit + } + + private var chevron: UIImageView = .build { imageView in + let imageName = StandardImageIdentifiers.Large.chevronRight + let image = UIImage(named: imageName)? + .withRenderingMode(.alwaysTemplate) + .imageFlippedForRightToLeftLayoutDirection() ?? UIImage() + imageView.image = image + imageView.contentMode = .scaleAspectFit + } + + init(mainMenuHelper: MainMenuInterface) { + self.mainMenuHelper = mainMenuHelper + super.init(frame: .zero) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + if #available(iOS 26.0, *) { + stack.layer.cornerRadius = stack.frame.height / 2 + } else { + stack.layer.cornerRadius = UX.cornerRadius + stack.layer.borderWidth = UX.borderWidth + } + } + + private func setupViews() { + addSubview(stack) + stack.addArrangedSubview(icon) + stack.addArrangedSubview(label) + stack.addArrangedSubview(chevron) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + + icon.widthAnchor.constraint(equalToConstant: UX.iconSize), + chevron.widthAnchor.constraint(equalToConstant: UX.chevronSize) + ]) + } + + func configure(text: String, iconName: String, useTemplate: Bool) { + label.text = text + let image: UIImage = useTemplate + ? UIImage(named: iconName)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + : UIImage(named: iconName) ?? UIImage() + icon.image = image + } + + func applyTheme(theme: Theme) { + label.textColor = theme.colors.textSecondary + stack.layer.borderColor = theme.colors.actionSecondaryHover.cgColor + if #available(iOS 26.0, *) { + stack.backgroundColor = theme.colors.layerSurfaceMedium + .withAlphaComponent(mainMenuHelper.backgroundAlpha()) + } else { + stack.backgroundColor = .clear + } + icon.tintColor = theme.colors.iconSecondary + chevron.tintColor = theme.colors.iconSecondary + } + + @objc + private func tapped() { + tapHandler?() + } +} diff --git a/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift b/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift index 4b52b59cd3b53..6528683ce1136 100644 --- a/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift +++ b/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift @@ -7,24 +7,31 @@ import Common import SiteImageView import ComponentLibrary +public struct MenuSiteAdBlockerBadgeData { + public let title: String + public let image: String + public let shouldUseRenderMode: Bool + + public init(title: String, image: String, shouldUseRenderMode: Bool) { + self.title = title + self.image = image + self.shouldUseRenderMode = shouldUseRenderMode + } +} + public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { private struct UX { static let closeButtonSize: CGFloat = 30 static let contentLabelsSpacing: CGFloat = 1 static let horizontalContentMargin: CGFloat = 16 static let favIconSize: CGFloat = 40 - static let siteProtectionsContentTopMargin: CGFloat = 4 - static let siteProtectionsContentCornerRadius: CGFloat = 12 - static let siteProtectionsContentBorderWidth: CGFloat = 1 - static let siteProtectionsContentHorizontalPadding: CGFloat = 10 - static let siteProtectionsContentVerticalPadding: CGFloat = 6 - static let siteProtectionsIcon: CGFloat = 16 - static let siteProtectionsMoreSettingsIcon: CGFloat = 20 - static let siteProtectionsContentSpacing: CGFloat = 4 + static let badgesTopMargin: CGFloat = 4 + static let badgesSpacing: CGFloat = 8 } public var closeButtonCallback: (() -> Void)? public var siteProtectionsButtonCallback: (() -> Void)? + public var adBlockerButtonCallback: (() -> Void)? public var mainMenuHelper: MainMenuInterface = MainMenuHelper() private var contentLabels: UIStackView = .build { stack in @@ -57,42 +64,26 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { button.setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate) ?? UIImage(), for: .normal) } - private lazy var siteProtectionsContent: UIStackView = .build { [weak self] stack in - guard let self else { return } - stack.isLayoutMarginsRelativeArrangement = true - stack.layoutMargins = UIEdgeInsets(top: UX.siteProtectionsContentVerticalPadding, - left: UX.siteProtectionsContentHorizontalPadding, - bottom: UX.siteProtectionsContentVerticalPadding, - right: UX.siteProtectionsContentHorizontalPadding) - stack.distribution = .fill + private lazy var badgesStack: UIStackView = .build { stack in stack.axis = .horizontal - stack.clipsToBounds = true - stack.spacing = UX.siteProtectionsContentSpacing - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(siteProtectionsTapped)) - stack.isUserInteractionEnabled = true - stack.addGestureRecognizer(tapGesture) - } - - private var siteProtectionsLabel: UILabel = .build { label in - label.font = FXFontStyles.Regular.footnote.scaledFont() - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - label.adjustsFontForContentSizeCategory = true - label.accessibilityTraits = .button + stack.alignment = .center + stack.distribution = .fill + stack.spacing = UX.badgesSpacing } - private var siteProtectionsIcon: UIImageView = .build { imageView in - imageView.contentMode = .scaleAspectFit - } + private lazy var siteProtectionsBadge: MenuSiteBadge = { + let badge = MenuSiteBadge(mainMenuHelper: mainMenuHelper) + badge.translatesAutoresizingMaskIntoConstraints = false + badge.tapHandler = { [weak self] in self?.siteProtectionsButtonCallback?() } + return badge + }() - private var siteProtectionsMoreSettingsIcon: UIImageView = .build { imageView in - let imageName = StandardImageIdentifiers.Large.chevronRight - let image = UIImage(named: imageName)? - .withRenderingMode(.alwaysTemplate) - .imageFlippedForRightToLeftLayoutDirection() ?? UIImage() - imageView.image = image - imageView.contentMode = .scaleAspectFit - } + private lazy var adBlockerBadge: MenuSiteBadge = { + let badge = MenuSiteBadge(mainMenuHelper: mainMenuHelper) + badge.translatesAutoresizingMaskIntoConstraints = false + badge.tapHandler = { [weak self] in self?.adBlockerButtonCallback?() } + return badge + }() init() { super.init(frame: .zero) @@ -103,34 +94,22 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { fatalError("init(coder:) has not been implemented") } - override public func layoutSubviews() { - super.layoutSubviews() - if #available(iOS 26.0, *) { - siteProtectionsContent.layer.cornerRadius = siteProtectionsContent.frame.height / 2 - } else { - siteProtectionsContent.layer.cornerRadius = UX.siteProtectionsContentCornerRadius - siteProtectionsContent.layer.borderWidth = UX.siteProtectionsContentBorderWidth - } - } - private func setupViews() { contentLabels.addArrangedSubview(titleLabel) contentLabels.addArrangedSubview(subtitleLabel) - addSubviews(contentLabels, favicon, closeButton, siteProtectionsContent) - siteProtectionsContent.addArrangedSubview(siteProtectionsIcon) - siteProtectionsContent.addArrangedSubview(siteProtectionsLabel) - siteProtectionsContent.addArrangedSubview(siteProtectionsMoreSettingsIcon) + addSubviews(contentLabels, favicon, closeButton, badgesStack) + badgesStack.addArrangedSubview(siteProtectionsBadge) - let siteProtectionsTopFromFavicon = siteProtectionsContent.topAnchor.constraint( + let badgesTopFromFavicon = badgesStack.topAnchor.constraint( greaterThanOrEqualTo: favicon.bottomAnchor, - constant: UX.siteProtectionsContentTopMargin + constant: UX.badgesTopMargin ) - let siteProtectionsTopFromLabels = siteProtectionsContent.topAnchor.constraint( + let badgesTopFromLabels = badgesStack.topAnchor.constraint( equalTo: contentLabels.bottomAnchor, - constant: UX.siteProtectionsContentTopMargin + constant: UX.badgesTopMargin ) - siteProtectionsTopFromLabels.priority = .defaultHigh + badgesTopFromLabels.priority = .defaultHigh NSLayoutConstraint.activate([ contentLabels.topAnchor.constraint(equalTo: self.topAnchor), contentLabels.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, @@ -145,17 +124,14 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -UX.horizontalContentMargin), closeButton.topAnchor.constraint(equalTo: self.topAnchor), - siteProtectionsIcon.widthAnchor.constraint(equalToConstant: UX.siteProtectionsIcon), - siteProtectionsMoreSettingsIcon.widthAnchor.constraint(equalToConstant: UX.siteProtectionsMoreSettingsIcon), - - siteProtectionsTopFromLabels, - siteProtectionsTopFromFavicon, - siteProtectionsContent.trailingAnchor.constraint(lessThanOrEqualTo: closeButton.leadingAnchor), - siteProtectionsContent.bottomAnchor.constraint(equalTo: self.bottomAnchor), + badgesTopFromLabels, + badgesTopFromFavicon, + badgesStack.leadingAnchor.constraint(equalTo: favicon.leadingAnchor), + badgesStack.trailingAnchor.constraint(lessThanOrEqualTo: closeButton.leadingAnchor), + badgesStack.bottomAnchor.constraint(equalTo: self.bottomAnchor), closeButton.widthAnchor.constraint(equalToConstant: UX.closeButtonSize), - closeButton.heightAnchor.constraint(equalToConstant: UX.closeButtonSize), - siteProtectionsContent.leadingAnchor.constraint(equalTo: favicon.leadingAnchor) + closeButton.heightAnchor.constraint(equalToConstant: UX.closeButtonSize) ]) closeButton.layer.cornerRadius = 0.5 * UX.closeButtonSize @@ -167,17 +143,24 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { image: String?, state: String, stateImage: String, - shouldUseRenderMode: Bool + shouldUseRenderMode: Bool, + adBlocker: MenuSiteAdBlockerBadgeData? = nil ) { titleLabel.text = title subtitleLabel.text = subtitle - siteProtectionsLabel.text = state - let siteProtectionsImage: UIImage = if shouldUseRenderMode { - UIImage(named: stateImage)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + siteProtectionsBadge.configure(text: state, + iconName: stateImage, + useTemplate: shouldUseRenderMode) + if let adBlocker { + if adBlockerBadge.superview == nil { + badgesStack.addArrangedSubview(adBlockerBadge) + } + adBlockerBadge.configure(text: adBlocker.title, + iconName: adBlocker.image, + useTemplate: adBlocker.shouldUseRenderMode) } else { - UIImage(named: stateImage) ?? UIImage() + adBlockerBadge.removeFromSuperview() } - siteProtectionsIcon.image = siteProtectionsImage let image = FaviconImageViewModel(siteURLString: image, faviconCornerRadius: UX.favIconSize / 2) @@ -197,25 +180,12 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { closeButtonCallback?() } - @objc - func siteProtectionsTapped() { - siteProtectionsButtonCallback?() - } - public func applyTheme(theme: Theme) { titleLabel.textColor = theme.colors.textPrimary subtitleLabel.textColor = theme.colors.textSecondary closeButton.tintColor = theme.colors.iconSecondary closeButton.backgroundColor = theme.colors.actionCloseButton.withAlphaComponent(mainMenuHelper.backgroundAlpha()) - siteProtectionsLabel.textColor = theme.colors.textSecondary - siteProtectionsContent.layer.borderColor = theme.colors.actionSecondaryHover.cgColor - if #available(iOS 26.0, *) { - let backgroundColor = theme.colors.layerSurfaceMedium.withAlphaComponent(mainMenuHelper.backgroundAlpha()) - siteProtectionsContent.backgroundColor = backgroundColor - } else { - siteProtectionsContent.backgroundColor = .clear - } - siteProtectionsIcon.tintColor = theme.colors.iconSecondary - siteProtectionsMoreSettingsIcon.tintColor = theme.colors.iconSecondary + siteProtectionsBadge.applyTheme(theme: theme) + adBlockerBadge.applyTheme(theme: theme) } } diff --git a/BrowserKit/Sources/Shared/Prefs.swift b/BrowserKit/Sources/Shared/Prefs.swift index 306065f08f007..7cc7035fcb3e1 100644 --- a/BrowserKit/Sources/Shared/Prefs.swift +++ b/BrowserKit/Sources/Shared/Prefs.swift @@ -50,6 +50,7 @@ public struct PrefsKeys { public static let ShowClipboardBar = "showClipboardBar" public static let ShowRelayMaskSuggestions = "showRelayMaskSuggestions" public static let BlockOpeningExternalApps = "blockOpeningExternalApps" + public static let BlockAds = "blockAds" public static let NewTabCustomUrlPrefKey = "HomePageURLPref" public static let GoogleTopSiteAddedKey = "googleTopSiteAddedKey" public static let GoogleTopSiteHideKey = "googleTopSiteHideKey" diff --git a/BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift b/BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift new file mode 100644 index 0000000000000..370b54e15a37c --- /dev/null +++ b/BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift @@ -0,0 +1,53 @@ +// 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 XCTest +@testable import MenuKit + +@MainActor +final class MenuSiteBadgeTests: XCTestCase { + var badge: MenuSiteBadge! + + override func setUp() async throws { + try await super.setUp() + badge = MenuSiteBadge(mainMenuHelper: MainMenuHelper()) + } + + override func tearDown() async throws { + badge = nil + try await super.tearDown() + } + + func testTapHandlerCallback() { + let expectation = XCTestExpectation(description: "Tap handler should be called") + badge.tapHandler = { expectation.fulfill() } + + badge.tapHandler?() + + wait(for: [expectation], timeout: 1.0) + } + + func testConfigure_setsVisibleLabelText() { + badge.configure(text: "Protections", iconName: "", useTemplate: false) + + let label = firstLabel(in: badge) + XCTAssertEqual(label?.text, "Protections") + } + + func testConfigure_updatesLabelOnSubsequentCalls() { + badge.configure(text: "Protections", iconName: "", useTemplate: false) + badge.configure(text: "Ad Blocker", iconName: "", useTemplate: false) + + let label = firstLabel(in: badge) + XCTAssertEqual(label?.text, "Ad Blocker") + } + + private func firstLabel(in view: UIView) -> UILabel? { + for subview in view.subviews { + if let label = subview as? UILabel { return label } + if let found = firstLabel(in: subview) { return found } + } + return nil + } +} diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json new file mode 100644 index 0000000000000..316a92e972a08 --- /dev/null +++ b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "adBlockerCheckmarkMedium.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/adBlockerCheckmarkMedium.pdf b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/adBlockerCheckmarkMedium.pdf new file mode 100644 index 0000000000000..f7e6cdb31015d Binary files /dev/null and b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/adBlockerCheckmarkMedium.pdf differ diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json new file mode 100644 index 0000000000000..9141d709ac9af --- /dev/null +++ b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "adBlockerCrossMedium.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/adBlockerCrossMedium.pdf b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/adBlockerCrossMedium.pdf new file mode 100644 index 0000000000000..e11b2a9154ad1 Binary files /dev/null and b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/adBlockerCrossMedium.pdf differ diff --git a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift index 1723f558d24fc..0f0190cb8bb9a 100644 --- a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift +++ b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift @@ -629,6 +629,27 @@ final class BrowserCoordinator: BaseCoordinator, showETPMenu(sourceView: browserViewController.addressToolbarContainer) } + func presentAdBlockerSettings() { + let browsingSettings = BrowsingSettingsViewController(profile: profile, windowUUID: windowUUID) + let navigationController = DismissableNavigationViewController(rootViewController: browsingSettings) + setupAdBlockerSettingsDetents(for: navigationController) + navigationController.sheetPresentationController?.prefersGrabberVisible = true + router.present(navigationController, animated: true) + } + + private func setupAdBlockerSettingsDetents(for controller: UIViewController) { + if #available(iOS 16.0, *) { + let customDetent = UISheetPresentationController.Detent.custom( + identifier: .init("threeQuarter") + ) { context in + context.maximumDetentValue * 0.75 + } + controller.sheetPresentationController?.detents = [customDetent, .large()] + } else { + controller.sheetPresentationController?.detents = [.medium(), .large()] + } + } + func presentSavePDFController() { guard let selectedTab = browserViewController.tabManager.selectedTab else { return } diff --git a/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift b/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift index e4b38048dda4e..705f3c78c4db3 100644 --- a/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift +++ b/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift @@ -7,6 +7,7 @@ import Shared /// An enum describing the featureID of all features found in Nimbus. /// Please add new features alphabetically. enum FeatureFlagID: String, CaseIterable { + case adBlocker case addressAutofillEdit case addressBarMenu case adsClient @@ -84,7 +85,8 @@ enum FeatureFlagID: String, CaseIterable { // Add in alphabetical order. var debugKey: String? { switch self { - case .addressBarMenu, + case .adBlocker, + .addressBarMenu, .adsClient, .aiKillSwitch, .appearanceMenu, diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift index 0d0e8c03ab2a5..2ae25d652e558 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift @@ -32,6 +32,9 @@ protocol MainMenuCoordinatorDelegate: AnyObject { @MainActor func presentSiteProtections() + @MainActor + func presentAdBlockerSettings() + @MainActor func showPrintSheet() @@ -157,6 +160,9 @@ class MainMenuCoordinator: BaseCoordinator { case .siteProtections: navigationHandler?.presentSiteProtections() + case .adBlocker: + navigationHandler?.presentAdBlockerSettings() + case .defaultBrowser: DefaultApplicationHelper().openSettings() diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift index cd65e0cfd9c5e..867ed990c551c 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift @@ -12,6 +12,7 @@ final class MainMenuMiddleware { private enum TelemetryAction { static let addToShortcuts = "add_to_shortcuts" static let bookmarks = "bookmarks" + static let adBlocker = "ad_blocker" static let bookmarkThisPage = "bookmark_this_page" static let defaultBrowserSettings = "default_browser_settings" static let downloads = "downloads" @@ -231,6 +232,9 @@ final class MainMenuMiddleware { case .siteProtections: self.telemetry.mainMenuOptionTapped(with: isHomepage, and: TelemetryAction.siteProtections) + case .adBlocker: + self.telemetry.mainMenuOptionTapped(with: isHomepage, and: TelemetryAction.adBlocker) + case .defaultBrowser: self.telemetry.mainMenuOptionTapped(with: isHomepage, and: TelemetryAction.defaultBrowserSettings) diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift index 70d4777cae40c..96fece4ff670f 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift @@ -6,6 +6,7 @@ import Foundation import SummarizeKit enum MainMenuNavigationDestination: Equatable { + case adBlocker case bookmarks case defaultBrowser case downloads @@ -31,6 +32,7 @@ enum MainMenuNavigationDestination: Equatable { /// revert to using CaseIterable on the enum. public static var allCasesForTests: [MainMenuNavigationDestination] { [ + .adBlocker, .bookmarks, .defaultBrowser, .downloads, diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift index 474850f8d0cb3..c5187fb8bbf68 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift @@ -15,6 +15,7 @@ class MainMenuViewController: UIViewController, UIScrollViewDelegate, Themeable, Notifiable, + FeatureFlaggable, StoreSubscriber { private struct UX { static let hintViewCornerRadius: CGFloat = 20 @@ -172,6 +173,10 @@ class MainMenuViewController: UIViewController, self?.dispatchSiteProtectionAction() } + menuContent.siteProtectionHeader.adBlockerButtonCallback = { [weak self] in + self?.dispatchAdBlockerAction() + } + menuContent.closeButtonCallback = { [weak self] in self?.dispatchCloseMenuAction() } @@ -448,6 +453,17 @@ class MainMenuViewController: UIViewController, ) } + private func dispatchAdBlockerAction() { + store.dispatch( + MainMenuAction( + windowUUID: self.windowUUID, + actionType: MainMenuActionType.tapNavigateToDestination, + navigationDestination: MenuNavigationDestination(.adBlocker), + currentTabInfo: menuState.currentTabInfo + ) + ) + } + private func dispatchDefaultBrowserAction() { store.dispatch( MainMenuAction( @@ -467,28 +483,38 @@ class MainMenuViewController: UIViewController, } private func updateSiteProtectionsHeaderWith(siteProtectionsData: SiteProtectionsData) { - var state = String.MainMenu.SiteProtection.ProtectionsOn + let state = String.MainMenu.SiteProtection.Protections var stateImage = StandardImageIdentifiers.Small.shieldCheckmarkFill var shouldUseRenderMode = false switch siteProtectionsData.state { case .notSecure: - state = String.MainMenu.SiteProtection.ConnectionNotSecure stateImage = StandardImageIdentifiers.Small.shieldSlashFillMulticolor case .on: shouldUseRenderMode = true case .off: - state = String.MainMenu.SiteProtection.ProtectionsOff stateImage = StandardImageIdentifiers.Small.shieldSlashFillMulticolor } + let adBlocker: MenuSiteAdBlockerBadgeData? = { + guard featureFlagsProvider.isEnabled(.adBlocker) else { return nil } + let isAdBlockerOn = profile.prefs.boolForKey(PrefsKeys.BlockAds) ?? false + let adBlockerImage = isAdBlockerOn + ? StandardImageIdentifiers.Medium.adBlockerCheckmark + : StandardImageIdentifiers.Medium.adBlockerCross + return MenuSiteAdBlockerBadgeData(title: String.MainMenu.SiteProtection.AdBlocker, + image: adBlockerImage, + shouldUseRenderMode: true) + }() + menuContent.siteProtectionHeader.setupDetails( title: siteProtectionsData.title, subtitle: siteProtectionsData.subtitle, image: siteProtectionsData.image, state: state, stateImage: stateImage, - shouldUseRenderMode: shouldUseRenderMode) + shouldUseRenderMode: shouldUseRenderMode, + adBlocker: adBlocker) } // MARK: - A11y diff --git a/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift b/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift index 7077f426fea5f..c888dd8e2a38c 100644 --- a/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift @@ -14,7 +14,7 @@ protocol BrowsingSettingsDelegate: AnyObject { func pressedAutoPlay() } -class BrowsingSettingsViewController: SettingsTableViewController { +class BrowsingSettingsViewController: SettingsTableViewController, FeatureFlaggable { weak var parentCoordinator: BrowsingSettingsDelegate? init(profile: Profile, @@ -40,7 +40,7 @@ class BrowsingSettingsViewController: SettingsTableViewController { var settings = [SettingSection]() var linksSettings: [Setting] = [OpenWithSetting(settings: self, settingsDelegate: parentCoordinator)] - var mediaSection = [Setting]() + var contentSection = [Setting]() if let profile { let theme = themeManager.getCurrentTheme(for: windowUUID) let offerToOpenCopiedLinksSettings = BoolSetting( @@ -76,18 +76,31 @@ class BrowsingSettingsViewController: SettingsTableViewController { prefs: profile.prefs, settingsDelegate: parentCoordinator) - mediaSection += [ - autoplaySetting, + contentSection.append(autoplaySetting) + if featureFlagsProvider.isEnabled(.adBlocker) { + contentSection.append(BoolSetting( + prefs: profile.prefs, + theme: theme, + prefKey: PrefsKeys.BlockAds, + defaultValue: false, + titleText: .Settings.Browsing.BlockAds + )) + } + contentSection += [ BlockPopupSetting(prefs: profile.prefs), NoImageModeSetting(profile: profile), blockOpeningExternalAppsSettings ] } + let contentSectionTitle = featureFlagsProvider.isEnabled(.adBlocker) + ? String.Settings.Browsing.Content + : String.Settings.Browsing.Media + settings += [SettingSection(title: NSAttributedString(string: .Settings.Browsing.Links), children: linksSettings), - SettingSection(title: NSAttributedString(string: .Settings.Browsing.Media), - children: mediaSection)] + SettingSection(title: NSAttributedString(string: contentSectionTitle), + children: contentSection)] return settings } 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 450f7e6fa32c5..3721af36a96ae 100644 --- a/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift @@ -31,6 +31,13 @@ final class FeatureFlagsDebugViewController: SettingsTableViewController, Featur // For better code readability and parsability in-app, please keep in alphabetical // order by titleText let children: [Setting] = [ + FeatureFlagsBoolSetting( + with: .adBlocker, + titleText: format(string: "Ad Blocker"), + statusText: format(string: "Toggle to show the Ad Blocker feature.") + ) { [weak self] _ in + self?.reloadView() + }, FeatureFlagsBoolSetting( with: .adsClient, titleText: format(string: "Ads Client"), diff --git a/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift b/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift index 4a2a2a5867c47..4ea6e5c0f5b79 100644 --- a/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift +++ b/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift @@ -21,6 +21,9 @@ final class NimbusFeatureFlagLayer: NimbusFeatureFlagLayerProviding, Sendable { public func checkNimbusConfigFor(_ featureID: FeatureFlagID) -> Bool { // For better code readability, please keep in alphabetical order by FeatureFlagID switch featureID { + case .adBlocker: + return checkAdBlockerFeature() + case .addressAutofillEdit: return checkAddressAutofillEditing() @@ -437,6 +440,10 @@ final class NimbusFeatureFlagLayer: NimbusFeatureFlagLayerProviding, Sendable { return nimbus.features.worldCupWidgetFeature.value().enabled } + private func checkAdBlockerFeature() -> Bool { + return nimbus.features.adBlockerFeature.value().enabled + } + func checkStartAtHomeConfiguration() -> StartAtHome { return nimbus.features.startAtHomeFeature.value().setting } diff --git a/firefox-ios/Shared/Strings.swift b/firefox-ios/Shared/Strings.swift index ab37792131291..d2babffb2cf9f 100644 --- a/firefox-ios/Shared/Strings.swift +++ b/firefox-ios/Shared/Strings.swift @@ -3362,6 +3362,18 @@ extension String { value: "Media", comment: "This is the title for Media customization under the Browsing settings section." ) + public static let Content = MZLocalizedString( + key: "Settings.Browsing.Content.v152", + tableName: "Settings", + value: "Content", + comment: "This is the title for the Content customization section under the Browsing settings page." + ) + public static let BlockAds = MZLocalizedString( + key: "Settings.Browsing.BlockAds.Title.v152", + tableName: "Settings", + value: "Block Ads", + comment: "Title for the Block Ads toggle in the Browsing settings page. When enabled, the app blocks advertisements." + ) } public struct AIControls { @@ -5861,6 +5873,16 @@ extension String { } public struct SiteProtection { + public static let Protections = MZLocalizedString( + key: "MainMenu.SiteProtection.Protections.Title.v200", + tableName: "MainMenu", + value: "Protections", + comment: "On the main menu, at the top, title for a badge that opens site protection settings.") + public static let AdBlocker = MZLocalizedString( + key: "MainMenu.SiteProtection.AdBlocker.Title.v200", + tableName: "MainMenu", + value: "Ad Blocker", + comment: "On the main menu, at the top, title for a badge next to Protections that opens the ad blocker setting.") public static let ProtectionsOn = MZLocalizedString( key: "MainMenu.SiteProtection.ProtectionsOn.Title.v141", tableName: "MainMenu", diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift index eb81856fc07d3..0c4726d3c25c5 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift @@ -320,6 +320,27 @@ final class MainMenuMiddlewareTests: XCTestCase, StoreTestUtility { XCTAssertEqual(savedExtras.option, "site_protections") } + func test_tapNavigateToDestination_adBlockerAction_sendTelemetryData() throws { + let action = getNavigationDestinationAction(for: .adBlocker) + let subject = createSubject() + + subject.mainMenuProvider(AppState(), action) + + let savedMetric = try XCTUnwrap( + mockGleanWrapper.savedEvents.first as? EventMetricType + ) + let savedExtras = try XCTUnwrap( + mockGleanWrapper.savedExtras.first as? GleanMetrics.AppMenu.MainMenuOptionSelectedExtra + ) + let expectedMetricType = type(of: GleanMetrics.AppMenu.mainMenuOptionSelected) + let resultMetricType = type(of: savedMetric) + let debugMessage = TelemetryDebugMessage(expectedMetric: expectedMetricType, resultMetric: resultMetricType) + + XCTAssertEqual(mockGleanWrapper.recordEventCalled, 1) + XCTAssert(resultMetricType == expectedMetricType, debugMessage.text) + XCTAssertEqual(savedExtras.option, "ad_blocker") + } + func test_tapNavigateToDestination_defaultBrowserAction_sendTelemetryData() throws { let action = getNavigationDestinationAction(for: .defaultBrowser) let subject = createSubject() diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift index 02d25b160b54c..00305705839e7 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift @@ -14,6 +14,7 @@ class MockMainMenuCoordinatorDelegate: MainMenuCoordinatorDelegate { private(set) var updateZoomPageBarVisibilityCalled = 0 private(set) var presentSavePDFControllerCalled = 0 private(set) var presentSiteProtectionsCalled = 0 + private(set) var presentAdBlockerSettingsCalled = 0 private(set) var showPrintSheetCalled = 0 private(set) var showReaderModeCalled = 0 private(set) var showShareSheetForCurrentlySelectedTabCalled = 0 @@ -52,6 +53,10 @@ class MockMainMenuCoordinatorDelegate: MainMenuCoordinatorDelegate { presentSiteProtectionsCalled += 1 } + func presentAdBlockerSettings() { + presentAdBlockerSettingsCalled += 1 + } + func showPrintSheet() { showPrintSheetCalled += 1 } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift index db5164718e1b7..c2cc62ce7c5bb 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift @@ -12,10 +12,12 @@ import XCTest final class BrowsingSettingsViewControllerTests: XCTestCase { private var profile: Profile! private var delegate: MockSettingsDelegate! + private var featureFlags: MockNimbusFeatureFlags! override func setUp() async throws { try await super.setUp() - DependencyHelperMock().bootstrapDependencies() + self.featureFlags = MockNimbusFeatureFlags() + DependencyHelperMock().bootstrapDependencies(injectedFeatureFlagProvider: featureFlags) self.profile = MockProfile() self.delegate = MockSettingsDelegate() } @@ -24,6 +26,7 @@ final class BrowsingSettingsViewControllerTests: XCTestCase { DependencyHelperMock().reset() self.profile = nil self.delegate = nil + self.featureFlags = nil try await super.tearDown() } @@ -32,6 +35,30 @@ final class BrowsingSettingsViewControllerTests: XCTestCase { trackForMemoryLeaks(subject) } + func testGenerateSettings_whenAdBlockerFlagOff_omitsBlockAdsAndUsesMediaSection() { + featureFlags.enabledFlags = [] + let subject = createSubject() + + let sections = subject.generateSettings() + let contentSection = sections.last + let titles = contentSection?.children.compactMap { $0.title?.string } ?? [] + + XCTAssertEqual(contentSection?.title?.string, String.Settings.Browsing.Media) + XCTAssertFalse(titles.contains(String.Settings.Browsing.BlockAds)) + } + + func testGenerateSettings_whenAdBlockerFlagOn_includesBlockAdsAndUsesContentSection() { + featureFlags.enabledFlags = [.adBlocker] + let subject = createSubject() + + let sections = subject.generateSettings() + let contentSection = sections.last + let titles = contentSection?.children.compactMap { $0.title?.string } ?? [] + + XCTAssertEqual(contentSection?.title?.string, String.Settings.Browsing.Content) + XCTAssertTrue(titles.contains(String.Settings.Browsing.BlockAds)) + } + // MARK: - Helper private func createSubject() -> BrowsingSettingsViewController { let subject = BrowsingSettingsViewController(profile: profile, diff --git a/firefox-ios/nimbus-features/adBlockerFeature.yaml b/firefox-ios/nimbus-features/adBlockerFeature.yaml new file mode 100644 index 0000000000000..464921bf85902 --- /dev/null +++ b/firefox-ios/nimbus-features/adBlockerFeature.yaml @@ -0,0 +1,19 @@ +# The configuration for the adBlocker feature +features: + ad-blocker-feature: + description: > + The feature flag to manage the roll out of the Ad Blocker badge in the + Site Menu and the corresponding Block Ads toggle in Browsing settings. + variables: + enabled: + description: > + Whether or not this feature is enabled + type: Boolean + default: false + defaults: + - channel: beta + value: + enabled: false + - channel: developer + value: + enabled: false diff --git a/firefox-ios/nimbus.fml.yaml b/firefox-ios/nimbus.fml.yaml index 55fcaf4203ce5..39913008b0ead 100644 --- a/firefox-ios/nimbus.fml.yaml +++ b/firefox-ios/nimbus.fml.yaml @@ -15,6 +15,7 @@ channels: include: + - nimbus-features/adBlockerFeature.yaml - nimbus-features/addressAutofillFeature.yaml - nimbus-features/adsClient.yaml - nimbus-features/aiKillSwitchFeature.yaml