diff --git a/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8f76e25eac2e..40df959e81cb 100644 --- a/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mapbox/mapbox-common-ios.git", "state" : { - "revision" : "47bcfcfb8c377456fa23429d705a43fa166617af", - "version" : "24.9.0-sapial-release-naive-sdks-1-daily-2024-11-14-17-16" + "revision" : "7e3b4bcbcd4eeda7e5225d869ffc8ea0c49a0dd4", + "version" : "24.9.0-beta.1" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mapbox/mapbox-core-maps-ios.git", "state" : { - "revision" : "345af077c1c6cc84afec71f76b2d09e761ca44e6", - "version" : "24.9.0-sapial-release-naive-sdks-1-daily-2024-11-14-17-16" + "revision" : "fc06924987026de895a024804052366d0836df69", + "version" : "11.9.0-beta.1" } }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bd47c98534..b912d0a7f9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ That initializer doesn't require to wrap arguments in `Argument` cases. For exam * Add a way to specify image expression options. * Bump core maps version to 11.9.0-beta.1 and common sdk to 24.9.0-beta.1 * Add new experimental APIs to control precipitation rendering. Snow and Rain are available now with an `@_spi(Experimental)` import prefix. +* Add a way to filter attribution menu items. ## 11.8.0 - 11 November, 2024 diff --git a/Sources/MapboxMaps/Attribution/AttributionMenu.swift b/Sources/MapboxMaps/Attribution/AttributionMenu.swift new file mode 100644 index 000000000000..c725c331db72 --- /dev/null +++ b/Sources/MapboxMaps/Attribution/AttributionMenu.swift @@ -0,0 +1,160 @@ +import Foundation +import UIKit +@_implementationOnly import MapboxCommon_Private + +/// API for attribution menu configuration +/// Restricted API. Please contact Mapbox to discuss your use case if you intend to use this property. +@_spi(Restricted) +public class AttributionMenu { + private let urlOpener: AttributionURLOpener + private let feedbackURLRef: Ref + private let geofencingIsActiveRef: Ref + + /// Filters attribution menu items based on the provided closure. + public var filter: ((AttributionMenuItem) -> Bool)? + + init( + urlOpener: AttributionURLOpener, + feedbackURLRef: Ref, + filter: ((AttributionMenuItem) -> Bool)? = nil, + geofencingIsActiveRef: Ref = Ref { __GeofencingUtils.isActive() } + ) { + self.urlOpener = urlOpener + self.filter = filter + self.feedbackURLRef = feedbackURLRef + self.geofencingIsActiveRef = geofencingIsActiveRef + } +} + +extension AttributionMenu { + var isMetricsEnabled: Bool { + get { UserDefaults.standard.MGLMapboxMetricsEnabled } + set { UserDefaults.standard.MGLMapboxMetricsEnabled = newValue } + } + + var isGeofencingEnabled: Bool { + get { __GeofencingUtils.getUserConsent() } + set { __GeofencingUtils.setUserConsent(isConsentGiven: newValue) { expected in + if let error = expected.error { Log.error("Error: \(error) when setting geofencing user consent.") } + } } + } + + internal func menu(from attributions: [Attribution]) -> AttributionMenuSection { + var elements = [AttributionMenuElement]() + let items = attributions.compactMap { attribution in + switch attribution.kind { + case .actionable(let url): + return AttributionMenuItem(title: attribution.localizedTitle, id: .copyright, category: .main) { [weak self] in + self?.urlOpener.openAttributionURL(url) + } + case .nonActionable: + return AttributionMenuItem(title: attribution.localizedTitle, id: .copyright, category: .main) + case .feedback: + guard let feedbackURL = feedbackURLRef.value else { return nil } + return AttributionMenuItem(title: attribution.localizedTitle, id: .contribute, category: .main) { [weak self] in + self?.urlOpener.openAttributionURL(feedbackURL) + } + } + } + let menuSubtitle: String? + if items.count == 1, let item = items.first, item.action == nil { + menuSubtitle = item.title + } else { + menuSubtitle = nil + elements.append(contentsOf: items.map(AttributionMenuElement.item)) + } + + elements.append(.section(telemetryMenu)) + + if geofencingIsActiveRef.value || !isGeofencingEnabled { + elements.append(.section(geofencingMenu)) + } + + elements.append(.item(privacyPolicyItem)) + elements.append(.item(cancelItem)) + + let mainTitle = Bundle.mapboxMaps.localizedString( + forKey: "SDK_NAME", + value: "Powered by Mapbox", + table: Ornaments.localizableTableName + ) + + return AttributionMenuSection(title: mainTitle, subtitle: menuSubtitle, category: .main, elements: elements) + } + + private var cancelItem: AttributionMenuItem { + let cancelTitle = NSLocalizedString("ATTRIBUTION_CANCEL", + tableName: Ornaments.localizableTableName, + bundle: .mapboxMaps, + value: "Cancel", + comment: "Title of button for dismissing attribution action sheet") + + return AttributionMenuItem(title: cancelTitle, style: .cancel, id: .cancel, category: .main) { } + } + + private var privacyPolicyItem: AttributionMenuItem { + let privacyPolicyTitle = NSLocalizedString("ATTRIBUTION_PRIVACY_POLICY", + tableName: Ornaments.localizableTableName, + bundle: .mapboxMaps, + value: "Mapbox Privacy Policy", + comment: "Privacy policy action in attribution sheet") + + return AttributionMenuItem(title: privacyPolicyTitle, id: .privacyPolicy, category: .main) { [weak self] in + self?.urlOpener.openAttributionURL(Attribution.privacyPolicyURL) + } + } + + private var telemetryMenu: AttributionMenuSection { + let telemetryTitle = TelemetryStrings.telemetryTitle + let telemetryURL = URL(string: Ornaments.telemetryURL)! + let message: String + let participateTitle: String + let declineTitle: String + + if isMetricsEnabled { + message = TelemetryStrings.telemetryEnabledMessage + participateTitle = TelemetryStrings.telemetryEnabledOnMessage + declineTitle = TelemetryStrings.telemetryEnabledOffMessage + } else { + message = TelemetryStrings.telemetryDisabledMessage + participateTitle = TelemetryStrings.telemetryDisabledOnMessage + declineTitle = TelemetryStrings.telemetryDisabledOffMessage + } + + return AttributionMenuSection(title: telemetryTitle, actionTitle: TelemetryStrings.telemetryName, subtitle: message, category: .telemetry, elements: [ + AttributionMenuItem(title: TelemetryStrings.telemetryMore, id: .telemetryInfo, category: .telemetry) { [weak self] in + self?.urlOpener.openAttributionURL(telemetryURL) + }, + AttributionMenuItem(title: declineTitle, id: .disable, category: .telemetry) { [weak self] in + self?.isMetricsEnabled = false + }, + AttributionMenuItem(title: participateTitle, style: .cancel, id: .enable, category: .telemetry) { [weak self] in + self?.isMetricsEnabled = true + } + ].map(AttributionMenuElement.item)) + } + + private var geofencingMenu: AttributionMenuSection { + let telemetryTitle = GeofencingStrings.geofencingTitle + let message = GeofencingStrings.geofencingMessage + let participateTitle: String + let declineTitle: String + + if __GeofencingUtils.getUserConsent() { + participateTitle = GeofencingStrings.geofencingEnabledOnMessage + declineTitle = GeofencingStrings.geofencingEnabledOffMessage + } else { + participateTitle = GeofencingStrings.geofencingDisabledOnMessage + declineTitle = GeofencingStrings.geofencingDisabledOffMessage + } + + return AttributionMenuSection(title: telemetryTitle, actionTitle: GeofencingStrings.geofencingName, subtitle: message, category: .geofencing, elements: [ + AttributionMenuItem(title: declineTitle, id: .disable, category: .geofencing) { [weak self] in + self?.isGeofencingEnabled = false + }, + AttributionMenuItem(title: participateTitle, style: .cancel, id: .enable, category: .geofencing) { [weak self] in + self?.isGeofencingEnabled = true + } + ].map(AttributionMenuElement.item)) + } +} diff --git a/Sources/MapboxMaps/Attribution/AttributionMenuItem.swift b/Sources/MapboxMaps/Attribution/AttributionMenuItem.swift new file mode 100644 index 000000000000..e438ca35ce4a --- /dev/null +++ b/Sources/MapboxMaps/Attribution/AttributionMenuItem.swift @@ -0,0 +1,91 @@ +import Foundation +import UIKit + +/// A menu item entry in the attribution list. +@_spi(Restricted) +public struct AttributionMenuItem { + + /// Denotes a category(section) that item belongs to. + public struct Category: RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Main(root) category + public static let main = Category(rawValue: "com.mapbox.maps.attribution.main") + + /// Category for opting in/out of telemetry + public static let telemetry = Category(rawValue: "com.mapbox.maps.attribution.telemetry") + + /// Category for opting in/out of geofencing + public static let geofencing = Category(rawValue: "com.mapbox.maps.attribution.geofencing") + } + + /// Denotes an identifier of an item + public struct ID: RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Item attributing a copyright + public static let copyright = ID(rawValue: "com.mapbox.maps.attribution.copyright") + + /// Represents an item opening a contribution form + public static let contribute = ID(rawValue: "com.mapbox.maps.attribution.contribute") + + /// Opens privacy policy page + public static let privacyPolicy = ID(rawValue: "com.mapbox.maps.attribution.privacyPolicy") + + /// Opens page with the info about Mapbox telemetry + public static let telemetryInfo = ID(rawValue: "com.mapbox.maps.attribution.telemetryInfo") + + /// Item that enables a certain option, typically associated with a category + /// e.g. `category: .telemetry, id: .enable` + public static let enable = ID(rawValue: "com.mapbox.maps.attribution.enable") + + /// Item that disables a certain option, typically associated with a category + /// e.g. `category: .telemetry, id: .disable` + public static let disable = ID(rawValue: "com.mapbox.maps.attribution.disable") + + /// Item that dismisses the attribution menu + public static let cancel = ID(rawValue: "com.mapbox.maps.attribution.cancel") + } + + /// Title of the attribution menu item + public let title: String + + /// Identifier of the item + public let id: ID + + /// Category of the item + public let category: Category + + let action: (() -> Void)? + let style: Style + + init(title: String, style: Style = .default, id: ID, category: Category, action: (() -> Void)? = nil) { + self.title = title + self.id = id + self.category = category + self.action = action + self.style = style + } +} + +extension AttributionMenuItem { + enum Style { + case `default` + case cancel + + var uiActionStyle: UIAlertAction.Style { + switch self { + case .default: return .default + case .cancel: return .cancel + } + } + } +} diff --git a/Sources/MapboxMaps/Attribution/AttributionMenuSection.swift b/Sources/MapboxMaps/Attribution/AttributionMenuSection.swift new file mode 100644 index 000000000000..06548b09e569 --- /dev/null +++ b/Sources/MapboxMaps/Attribution/AttributionMenuSection.swift @@ -0,0 +1,35 @@ +import Foundation +import UIKit + +indirect enum AttributionMenuElement { + case section(AttributionMenuSection) + case item(AttributionMenuItem) +} + +internal struct AttributionMenuSection { + var title: String + var actionTitle: String? + var subtitle: String? + var category: AttributionMenuItem.Category + var elements: [AttributionMenuElement] + + init(title: String, actionTitle: String? = nil, subtitle: String? = nil, category: AttributionMenuItem.Category, elements: [AttributionMenuElement]) { + self.title = title + self.actionTitle = actionTitle + self.subtitle = subtitle + self.category = category + self.elements = elements + } + + mutating func filter(_ filter: (AttributionMenuItem) -> Bool) { + elements = elements.compactMap { element in + switch element { + case .item(let item): + return filter(item) ? .item(item) : nil + case .section(var section): + section.filter(filter) + return .section(section) + } + } + } +} diff --git a/Sources/MapboxMaps/Foundation/MapView+Attribution.swift b/Sources/MapboxMaps/Foundation/MapView+Attribution.swift index 039b58c46c23..6766db5e5b52 100644 --- a/Sources/MapboxMaps/Foundation/MapView+Attribution.swift +++ b/Sources/MapboxMaps/Foundation/MapView+Attribution.swift @@ -5,23 +5,11 @@ extension MapView: AttributionDialogManagerDelegate { func viewControllerForPresenting(_ attributionDialogManager: AttributionDialogManager) -> UIViewController? { parentViewController?.topmostPresentedViewController } +} - func attributionDialogManager(_ attributionDialogManager: AttributionDialogManager, didTriggerActionFor attribution: Attribution) { - switch attribution.kind { - case .actionable(let url): - Log.debug("Open url: \(url))", category: "Attribution") - attributionUrlOpener.openAttributionURL(url) - case .feedback: - let url = mapboxFeedbackURL() - Log.debug("Open url: \(url))", category: "Attribution") - attributionUrlOpener.openAttributionURL(url) - case .nonActionable: - break - } - } - - internal func mapboxFeedbackURL(accessToken: String = MapboxOptions.accessToken) -> URL { - let cameraState = self.mapboxMap.cameraState +internal extension MapboxMap { + func mapboxFeedbackURL(accessToken: String = MapboxOptions.accessToken) -> URL { + let cameraState = self.cameraState var components = URLComponents(string: "https://apps.mapbox.com/feedback/")! components.fragment = String(format: "/%.5f/%.5f/%.2f/%.1f/%i", @@ -38,7 +26,7 @@ extension MapView: AttributionDialogManagerDelegate { let sdkVersion = Bundle.mapboxMapsMetadata.version - if let styleURIString = mapboxMap.styleURI?.rawValue, + if let styleURIString = styleURI?.rawValue, let styleURL = URL(string: styleURIString), styleURL.scheme == "mapbox", styleURL.host == "styles" { diff --git a/Sources/MapboxMaps/Foundation/MapView.swift b/Sources/MapboxMaps/Foundation/MapView.swift index ff95abb380e3..8cfb97d08b88 100644 --- a/Sources/MapboxMaps/Foundation/MapView.swift +++ b/Sources/MapboxMaps/Foundation/MapView.swift @@ -7,6 +7,11 @@ import MetalKit // swiftlint:disable:next type_body_length open class MapView: UIView, SizeTrackingLayerDelegate { + /// Handles attribution menu customization + /// Restricted API. Please contact Mapbox to discuss your use case if you intend to use this property. + @_spi(Restricted) + public private(set) var attributionMenu: AttributionMenu! + open override class var layerClass: AnyClass { SizeTrackingLayer.self } // `mapboxMap` depends on `MapInitOptions`, which is not available until @@ -383,10 +388,15 @@ open class MapView: UIView, SizeTrackingLayerDelegate { mapboxMap: mapboxMap, cameraAnimationsManager: internalCamera) - // Initialize the attribution manager + // Initialize the attribution manager and menu + attributionMenu = AttributionMenu( + urlOpener: attributionUrlOpener, + feedbackURLRef: Ref { [weak mapboxMap] in mapboxMap?.mapboxFeedbackURL() } + ) attributionDialogManager = AttributionDialogManager( dataSource: mapboxMap, - delegate: self) + delegate: self, + attributionMenu: attributionMenu) // Initialize/Configure ornaments manager ornaments = OrnamentsManager( diff --git a/Sources/MapboxMaps/Ornaments/InfoButtonOrnament.swift b/Sources/MapboxMaps/Ornaments/InfoButtonOrnament.swift index 6542184642bb..e444451115a4 100644 --- a/Sources/MapboxMaps/Ornaments/InfoButtonOrnament.swift +++ b/Sources/MapboxMaps/Ornaments/InfoButtonOrnament.swift @@ -15,10 +15,6 @@ internal class InfoButtonOrnament: UIView { } } - internal var isMetricsEnabled: Bool { - return UserDefaults.standard.MGLMapboxMetricsEnabled - } - internal weak var delegate: InfoButtonOrnamentDelegate? internal init() { diff --git a/Sources/MapboxMaps/Style/Attribution.swift b/Sources/MapboxMaps/Style/Attribution.swift index 3e6c8de371b5..6aa80c2beadd 100644 --- a/Sources/MapboxMaps/Style/Attribution.swift +++ b/Sources/MapboxMaps/Style/Attribution.swift @@ -1,5 +1,6 @@ import Foundation import WebKit +@_implementationOnly import MapboxCommon_Private struct Attribution: Hashable { @@ -21,7 +22,7 @@ struct Attribution: Hashable { "https://www.mapbox.com/map-feedback/", "https://apps.mapbox.com/feedback/" ] - private static let privacyPolicyURL = URL(string: "https://www.mapbox.com/legal/privacy#product-privacy-policy") + internal static let privacyPolicyURL = URL(string: "https://www.mapbox.com/legal/privacy#product-privacy-policy")! var title: String var kind: Kind diff --git a/Sources/MapboxMaps/Style/AttributionDialogManager.swift b/Sources/MapboxMaps/Style/AttributionDialogManager.swift index 4093be81c987..97150f0ee900 100644 --- a/Sources/MapboxMaps/Style/AttributionDialogManager.swift +++ b/Sources/MapboxMaps/Style/AttributionDialogManager.swift @@ -8,7 +8,6 @@ protocol AttributionDataSource: AnyObject { protocol AttributionDialogManagerDelegate: AnyObject { func viewControllerForPresenting(_ attributionDialogManager: AttributionDialogManager) -> UIViewController? - func attributionDialogManager(_ attributionDialogManager: AttributionDialogManager, didTriggerActionFor attribution: Attribution) } final class AttributionDialogManager { @@ -16,92 +15,29 @@ final class AttributionDialogManager { private weak var delegate: AttributionDialogManagerDelegate? private var inProcessOfParsingAttributions: Bool = false - private let isGeofenceActive: () -> Bool - private let setGeofenceConsent: (Bool) -> Void - private let getGeofenceConsent: () -> Bool + private let attributionMenu: AttributionMenu init( dataSource: AttributionDataSource, delegate: AttributionDialogManagerDelegate?, - isGeofenceActive: @escaping () -> Bool = { __GeofencingUtils.isActive() }, - setGeofenceConsent: @escaping (Bool) -> Void = { isConsentGiven in - __GeofencingUtils.setUserConsent(isConsentGiven: isConsentGiven, callback: { expected in - if let error = expected.error { Log.error("Error: \(error) occurred while changing user consent for Geofencing.") } - }) - }, - getGeofenceConsent: @escaping () -> Bool = { __GeofencingUtils.getUserConsent() } + attributionMenu: AttributionMenu ) { self.dataSource = dataSource self.delegate = delegate - self.isGeofenceActive = isGeofenceActive - self.setGeofenceConsent = setGeofenceConsent - self.getGeofenceConsent = getGeofenceConsent - } - - var isMetricsEnabled: Bool { - get { UserDefaults.standard.MGLMapboxMetricsEnabled } - set { UserDefaults.standard.MGLMapboxMetricsEnabled = newValue } - } - - func showGeofencingAlertController(from viewController: UIViewController) { - let telemetryTitle = GeofencingStrings.geofencingTitle - let message = GeofencingStrings.geofencingMessage - let participateTitle: String - let declineTitle: String - - if getGeofenceConsent() { - participateTitle = GeofencingStrings.geofencingEnabledOnMessage - declineTitle = GeofencingStrings.geofencingEnabledOffMessage - } else { - participateTitle = GeofencingStrings.geofencingDisabledOnMessage - declineTitle = GeofencingStrings.geofencingDisabledOffMessage - } - - showAlertController(from: viewController, title: telemetryTitle, message: message, actions: [ - UIAlertAction(title: declineTitle, style: .default, handler: { _ in self.setGeofenceConsent(false) }), - UIAlertAction(title: participateTitle, style: .cancel, handler: { _ in self.setGeofenceConsent(true) }) - ]) - } - - func showTelemetryAlertController(from viewController: UIViewController) { - let telemetryTitle = TelemetryStrings.telemetryTitle - let message: String - let participateTitle: String - let declineTitle: String - - if isMetricsEnabled { - message = TelemetryStrings.telemetryEnabledMessage - participateTitle = TelemetryStrings.telemetryEnabledOnMessage - declineTitle = TelemetryStrings.telemetryEnabledOffMessage - } else { - message = TelemetryStrings.telemetryDisabledMessage - participateTitle = TelemetryStrings.telemetryDisabledOnMessage - declineTitle = TelemetryStrings.telemetryDisabledOffMessage - } - - let openTelemetryURL: (UIAlertAction) -> Void = { _ in - guard let url = URL(string: Ornaments.telemetryURL) else { return } - self.delegate?.attributionDialogManager(self, didTriggerActionFor: Attribution(title: "", url: url)) - } - - showAlertController(from: viewController, title: telemetryTitle, message: message, actions: [ - UIAlertAction(title: TelemetryStrings.telemetryMore, style: .default, handler: openTelemetryURL), - UIAlertAction(title: declineTitle, style: .default, handler: { _ in self.isMetricsEnabled = false }), - UIAlertAction(title: participateTitle, style: .cancel, handler: { _ in self.isMetricsEnabled = true }) - ]) + self.attributionMenu = attributionMenu } func showAlertController( from viewController: UIViewController, - title: String, - message: String, - actions: [UIAlertAction] + title: String? = nil, + message: String? = nil, + actions: [UIAlertAction] = [] ) { - let alert = if UIDevice.current.userInterfaceIdiom == .pad { - UIAlertController(title: title, message: message, preferredStyle: .alert) - } else { - UIAlertController(title: title, message: message, preferredStyle: .actionSheet) - } + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet + ) actions.forEach(alert.addAction) viewController.present(alert, animated: true) @@ -114,76 +50,47 @@ extension AttributionDialogManager: InfoButtonOrnamentDelegate { guard inProcessOfParsingAttributions == false else { return } inProcessOfParsingAttributions = true + dataSource?.loadAttributions { [weak self] attributions in - self?.showAttributionDialog(for: attributions) - self?.inProcessOfParsingAttributions = false + guard let self else { return } + var menu = self.attributionMenu.menu(from: attributions) + if let filter = self.attributionMenu.filter { + menu.filter(filter) + } + showAttributionDialog(for: menu) + self.inProcessOfParsingAttributions = false } } - private func showAttributionDialog(for attributions: [Attribution]) { + private func showAttributionDialog(for menu: AttributionMenuSection) { guard let viewController = delegate?.viewControllerForPresenting(self) else { Log.error("Failed to present an attribution dialogue: no presenting view controller found.") return } - let title = Bundle.mapboxMaps.localizedString(forKey: "SDK_NAME", value: "Powered by Mapbox", table: Ornaments.localizableTableName) - - let alert: UIAlertController - - if UIDevice.current.userInterfaceIdiom == .pad { - alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) - } else { - alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - } - - let bundle = Bundle.mapboxMaps - - // Non actionable single item gets displayed as alert's message - if attributions.count == 1, let attribution = attributions.first, attribution.kind == .nonActionable { - alert.message = attribution.localizedTitle - } else { - for attribution in attributions { - let action = UIAlertAction(title: attribution.localizedTitle, style: .default) { _ in - self.delegate?.attributionDialogManager(self, didTriggerActionFor: attribution) + let actions = menu.elements.compactMap { element in + switch element { + case .item(let item): + let action = UIAlertAction(title: item.title, style: item.style.uiActionStyle) { _ in + item.action?() + } + action.isEnabled = item.action != nil + return action + case .section(let section): + if section.elements.isEmpty { + return nil + } + return UIAlertAction(title: section.actionTitle, style: .default) { _ in + self.showAttributionDialog(for: section) } - action.isEnabled = attribution.kind != .nonActionable - alert.addAction(action) - } - } - - let telemetryAction = UIAlertAction(title: TelemetryStrings.telemetryName, style: .default) { _ in - self.showTelemetryAlertController(from: viewController) - } - - alert.addAction(telemetryAction) - - if isGeofenceActive() || !getGeofenceConsent() { - let geofencingAction = UIAlertAction(title: GeofencingStrings.geofencingName, style: .default) { _ in - self.showGeofencingAlertController(from: viewController) } - alert.addAction(geofencingAction) } - let privacyPolicyAttribution = Attribution.makePrivacyPolicyAttribution() - let privacyPolicyAction = UIAlertAction(title: privacyPolicyAttribution.title, style: .default) { _ in - self.delegate?.attributionDialogManager(self, didTriggerActionFor: privacyPolicyAttribution) - } - - alert.addAction(privacyPolicyAction) - - let cancelTitle = NSLocalizedString("ATTRIBUTION_CANCEL", - tableName: Ornaments.localizableTableName, - bundle: bundle, - value: "Cancel", - comment: "Title of button for dismissing attribution action sheet") - - alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) - - viewController.present(alert, animated: true, completion: nil) + showAlertController(from: viewController, title: menu.title, message: menu.subtitle, actions: actions) } } -private extension Attribution { +internal extension Attribution { var localizedTitle: String { NSLocalizedString( title, diff --git a/Sources/MapboxMaps/SwiftUI/Deps.swift b/Sources/MapboxMaps/SwiftUI/Deps.swift index 448405274f8a..43c113a99da2 100644 --- a/Sources/MapboxMaps/SwiftUI/Deps.swift +++ b/Sources/MapboxMaps/SwiftUI/Deps.swift @@ -21,6 +21,7 @@ struct MapDependencies { var additionalSafeArea = SwiftUI.EdgeInsets() var viewportOptions = ViewportOptions(transitionsToIdleUponUserInteraction: true, usesSafeAreaInsetsAsPadding: true) var performanceStatisticsParameters: Map.PerformanceStatisticsParameters? + var attributionMenuFilter: ((AttributionMenuItem) -> Bool)? var onMapTap: ((InteractionContext) -> Void)? var onMapLongPress: ((InteractionContext) -> Void)? diff --git a/Sources/MapboxMaps/SwiftUI/Map.swift b/Sources/MapboxMaps/SwiftUI/Map.swift index 59c33cca0bc3..d8ce1394eecb 100644 --- a/Sources/MapboxMaps/SwiftUI/Map.swift +++ b/Sources/MapboxMaps/SwiftUI/Map.swift @@ -222,6 +222,14 @@ extension Map { @available(iOS 13.0, *) public extension Map { + + /// Filters attribution menu items + /// Restricted API. Please contact Mapbox to discuss your use case if you intend to use this property. + @_spi(Restricted) + func attributionMenuFilter(_ filter: @escaping (AttributionMenuItem) -> Bool) -> Self { + copyAssigned(self, \.mapDependencies.attributionMenuFilter, filter) + } + /// Sets camera bounds. func cameraBounds(_ cameraBounds: CameraBoundsOptions) -> Self { copyAssigned(self, \.mapDependencies.cameraBounds, cameraBounds) diff --git a/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift b/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift index 911f4f2f3af4..4717d73135fa 100644 --- a/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift +++ b/Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift @@ -99,6 +99,7 @@ final class MapBasicCoordinator { cameraChangeHandlers = deps.cameraChangeHandlers mapView.gestureManager.gestureHandlers = deps.gestureHandlers + mapView.attributionMenu.filter = deps.attributionMenuFilter shortLivedSubscriptions.removeAll() diff --git a/Sources/MapboxMaps/SwiftUI/MapViewFacade.swift b/Sources/MapboxMaps/SwiftUI/MapViewFacade.swift index 7889e7e501c4..3d4029127ae4 100644 --- a/Sources/MapboxMaps/SwiftUI/MapViewFacade.swift +++ b/Sources/MapboxMaps/SwiftUI/MapViewFacade.swift @@ -17,6 +17,7 @@ struct MapViewFacade { var presentationTransactionMode: PresentationTransactionMode @MutableRef var frameRate: Map.FrameRate + var attributionMenu: AttributionMenu var makeViewportTransition: (ViewportAnimation) -> ViewportTransition var makeViewportState: (Viewport, LayoutDirection) -> ViewportState? @@ -34,7 +35,7 @@ extension MapViewFacade { _isOpaque = MutableRef(root: mapView, keyPath: \.isOpaque) _presentationTransactionMode = MutableRef(root: mapView, keyPath: \.presentationTransactionMode) _frameRate = MutableRef(get: mapView.getFrameRate, set: mapView.set(frameRate:)) - + attributionMenu = mapView.attributionMenu makeViewportTransition = { animation in animation.makeViewportTransition(mapView) } diff --git a/Tests/MapboxMapsTests/Foundation/MapViewTests.swift b/Tests/MapboxMapsTests/Foundation/MapViewTests.swift index 0e8c7bd38f8d..5cc12f4e70a3 100644 --- a/Tests/MapboxMapsTests/Foundation/MapViewTests.swift +++ b/Tests/MapboxMapsTests/Foundation/MapViewTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable @_spi(Metrics) import MapboxMaps +@testable @_spi(Metrics) @_spi(Restricted) import MapboxMaps final class MapViewTests: XCTestCase { @@ -352,12 +352,17 @@ final class MapViewTests: XCTestCase { XCTAssertEqual(notificationCenter.addObserverStub.invocations[5].parameters.name, UIApplication.didReceiveMemoryWarningNotification) } - func testURLOpener() { - let manager = AttributionDialogManager(dataSource: MockAttributionDataSource(), delegate: MockAttributionDialogManagerDelegate()) + func testURLOpener() throws { + let attributionMenu = AttributionMenu(urlOpener: attributionURLOpener, feedbackURLRef: Ref { nil }) let url = URL(string: "http://example.com")! let attribution = Attribution(title: .randomASCII(withLength: 10), url: url) - mapView.attributionDialogManager(manager, didTriggerActionFor: attribution) + let menu = attributionMenu.menu(from: [attribution]) + guard let item = menu.elements.first, case let AttributionMenuElement.item(menuItem) = item else { + XCTFail("Failed to unwrap AttributionMenuElement.item") + return + } + menuItem.action?() XCTAssertEqual(attributionURLOpener.openAttributionURLStub.invocations.count, 1) XCTAssertEqual(attributionURLOpener.openAttributionURLStub.invocations.first?.parameters, url) diff --git a/Tests/MapboxMapsTests/Ornaments/InfoButton/InfoButtonOrnamentTests.swift b/Tests/MapboxMapsTests/Ornaments/InfoButton/InfoButtonOrnamentTests.swift index 6d9c867fc2fb..28a780787704 100644 --- a/Tests/MapboxMapsTests/Ornaments/InfoButton/InfoButtonOrnamentTests.swift +++ b/Tests/MapboxMapsTests/Ornaments/InfoButton/InfoButtonOrnamentTests.swift @@ -1,10 +1,12 @@ import XCTest -@testable import MapboxMaps +@_spi(Restricted) @testable import MapboxMaps class InfoButtonOrnamentTests: XCTestCase { var parentViewController: MockParentViewController! var attributionDialogManager: AttributionDialogManager! + var urlOpener: AttributionURLOpener! + var attributionMenu: AttributionMenu! var tapCompletion: (() -> Void)? private var isGeofenceActive: Bool = false @@ -13,15 +15,23 @@ class InfoButtonOrnamentTests: XCTestCase { override func setUp() { super.setUp() parentViewController = MockParentViewController() + urlOpener = MockAttributionURLOpener() + attributionMenu = AttributionMenu(urlOpener: urlOpener, feedbackURLRef: Ref { nil }) attributionDialogManager = AttributionDialogManager( dataSource: self, delegate: self, - isGeofenceActive: { self.isGeofenceActive }, - setGeofenceConsent: { self.isGeofenceConsentGiven = $0 }, - getGeofenceConsent: { self.isGeofenceConsentGiven } + attributionMenu: attributionMenu ) } + override func tearDown() { + urlOpener = nil + attributionMenu = nil + parentViewController = nil + attributionDialogManager = nil + super.tearDown() + } + func testInfoButtonTapped() throws { let infoButton = InfoButtonOrnament() infoButton.delegate = attributionDialogManager @@ -61,13 +71,13 @@ class InfoButtonOrnamentTests: XCTestCase { let participatingTitle = NSLocalizedString("Keep Participating", comment: "Telemetry prompt button") XCTAssertEqual(participatingTitle, telemetryAlert.actions[2].title, "The third action should be a 'Keep Participating' button.") - XCTAssertTrue(infoButton.isMetricsEnabled) + XCTAssertTrue(attributionMenu.isMetricsEnabled) let stopParticipatingTitle = NSLocalizedString("Stop Participating", comment: "Telemetry prompt button") XCTAssertEqual(stopParticipatingTitle, telemetryAlert.actions[1].title, "The second action should be a 'Stop Participating' button.") telemetryAlert.tapButton(atIndex: 1) - XCTAssertFalse(infoButton.isMetricsEnabled, "Metrics should not be enabled after selecting 'Stop participating'.") + XCTAssertFalse(attributionMenu.isMetricsEnabled, "Metrics should not be enabled after selecting 'Stop participating'.") infoButton.infoTapped() infoAlert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") @@ -77,7 +87,7 @@ class InfoButtonOrnamentTests: XCTestCase { let dontParticipateTitle = NSLocalizedString("Don’t Participate", comment: "Telemetry prompt button") XCTAssertEqual(dontParticipateTitle, telemetryAlert.actions[1].title, "The second action should be a 'Don't Participate' button.") telemetryAlert.tapButton(atIndex: 1) - XCTAssertFalse(infoButton.isMetricsEnabled, "Metrics should not be enabled after selecting 'Don't Participate'.") + XCTAssertFalse(attributionMenu.isMetricsEnabled, "Metrics should not be enabled after selecting 'Don't Participate'.") } func testTelemetryOptIn() throws { @@ -99,13 +109,13 @@ class InfoButtonOrnamentTests: XCTestCase { let participatingTitle = NSLocalizedString("Participate", comment: "Telemetry prompt button") XCTAssertEqual(participatingTitle, telemetryAlert.actions[2].title, "The third action should be a 'Participate' button.") - XCTAssertFalse(infoButton.isMetricsEnabled) + XCTAssertFalse(attributionMenu.isMetricsEnabled) let dontParticipateTitle = NSLocalizedString("Don’t Participate", comment: "Telemetry prompt button") XCTAssertEqual(dontParticipateTitle, telemetryAlert.actions[1].title, "The second action should be a 'Don't Participate' button.") telemetryAlert.tapButton(atIndex: 2) - XCTAssertTrue(infoButton.isMetricsEnabled, "Metrics should be enabled after selecting 'Participate'.") + XCTAssertTrue(attributionMenu.isMetricsEnabled, "Metrics should be enabled after selecting 'Participate'.") infoButton.infoTapped() infoAlert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") @@ -114,7 +124,7 @@ class InfoButtonOrnamentTests: XCTestCase { let keepParticipatingTitle = NSLocalizedString("Keep Participating", comment: "Telemetry prompt button") XCTAssertEqual(keepParticipatingTitle, telemetryAlert.actions[2].title, "The third action should be a 'Keep Participating' button.") telemetryAlert.tapButton(atIndex: 2) - XCTAssertTrue(infoButton.isMetricsEnabled, "Metrics should be enabled after selecting 'Keep Participating'.") + XCTAssertTrue(attributionMenu.isMetricsEnabled, "Metrics should be enabled after selecting 'Keep Participating'.") } } diff --git a/Tests/MapboxMapsTests/Style/AttributionDialogTests.swift b/Tests/MapboxMapsTests/Style/AttributionDialogTests.swift index b03206d97dee..bcbacd826f7b 100644 --- a/Tests/MapboxMapsTests/Style/AttributionDialogTests.swift +++ b/Tests/MapboxMapsTests/Style/AttributionDialogTests.swift @@ -1,167 +1,135 @@ import XCTest -@testable import MapboxMaps +@_spi(Restricted) @testable import MapboxMaps import Foundation import UIKit class AttributionDialogTests: XCTestCase { + var parentViewController: MockParentViewController! var attributionDialogManager: AttributionDialogManager! + var attributionMenu: AttributionMenu! + var urlOpener: AttributionURLOpener! var mockDataSource: MockAttributionDataSource! - var mockDelegate: MockAttributionDialogManagerDelegate! + var isGeofencingActive = false private var isGeofenceActive: Bool = false private var isGeofenceConsentGiven: Bool = true override func setUp() { super.setUp() + + parentViewController = MockParentViewController() mockDataSource = MockAttributionDataSource() - mockDelegate = MockAttributionDialogManagerDelegate() + urlOpener = MockAttributionURLOpener() + attributionMenu = AttributionMenu( + urlOpener: urlOpener, + feedbackURLRef: Ref { nil }, + geofencingIsActiveRef: Ref { [unowned self] in self.isGeofenceActive } + ) + attributionMenu.isGeofencingEnabled = true attributionDialogManager = AttributionDialogManager( dataSource: mockDataSource, - delegate: mockDelegate, - isGeofenceActive: { self.isGeofenceActive }, - setGeofenceConsent: { self.isGeofenceConsentGiven = $0 }, - getGeofenceConsent: { self.isGeofenceConsentGiven } + delegate: self, + attributionMenu: attributionMenu ) } override func tearDown() { super.tearDown() + attributionMenu.isGeofencingEnabled = true + isGeofenceActive = false + parentViewController = nil + attributionMenu = nil + urlOpener = nil attributionDialogManager = nil mockDataSource = nil - mockDelegate = nil } - func testShowGeofencingDialogGeofencingEnabled() throws { - let viewController = UIViewController() - let window = UIWindow() - window.rootViewController = viewController - window.makeKeyAndVisible() - isGeofenceConsentGiven = true + func testGeofencingOptIn() throws { + attributionMenu.isGeofencingEnabled = false + isGeofenceActive = true + attributionMenu.filter = { $0.category == .geofencing } - attributionDialogManager.showGeofencingAlertController(from: viewController) + attributionDialogManager.didTap(InfoButtonOrnament()) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) - let geofenceTitle = GeofencingStrings.geofencingTitle - XCTAssertEqual(alert.title, geofenceTitle) + var infoAlert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") + XCTAssertNotNil(infoAlert) + infoAlert.tapButton(atIndex: 0) - let message = GeofencingStrings.geofencingMessage - XCTAssertEqual(alert.message, message) + var geofencingAlert = try XCTUnwrap(parentViewController.currentAlert, "The geofencing alert controller could not be found.") - guard alert.actions.count == 2 else { - XCTFail("Telemetry alert should have 2 actions") + XCTAssertEqual(geofencingAlert.title, GeofencingStrings.geofencingTitle) + XCTAssertEqual(geofencingAlert.message, GeofencingStrings.geofencingMessage) + + guard geofencingAlert.actions.count == 2 else { + XCTFail("Geofencing alert should have 2 actions") return } - let declineTitle = GeofencingStrings.geofencingEnabledOffMessage - XCTAssertEqual(alert.actions[0].title, declineTitle) - - let participateTitle = GeofencingStrings.geofencingEnabledOnMessage - XCTAssertEqual(alert.actions[1].title, participateTitle) - } - - func testShowGeofencingDialogGeofencingDisabled() throws { - let viewController = UIViewController() - let bundle = Bundle.mapboxMaps - let window = UIWindow() - window.rootViewController = viewController - window.makeKeyAndVisible() - isGeofenceConsentGiven = false + XCTAssertEqual(GeofencingStrings.geofencingDisabledOffMessage, geofencingAlert.actions[0].title) + XCTAssertEqual(GeofencingStrings.geofencingDisabledOnMessage, geofencingAlert.actions[1].title) + XCTAssertFalse(attributionMenu.isGeofencingEnabled) - attributionDialogManager.showGeofencingAlertController(from: viewController) + geofencingAlert.tapButton(atIndex: 1) + XCTAssertTrue(attributionMenu.isGeofencingEnabled) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) - let geofenceTitle = GeofencingStrings.geofencingTitle - XCTAssertEqual(alert.title, geofenceTitle) + attributionDialogManager.didTap(InfoButtonOrnament()) - let message = GeofencingStrings.geofencingMessage - XCTAssertEqual(alert.message, message) + infoAlert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") + infoAlert.tapButton(atIndex: 0) + geofencingAlert = try XCTUnwrap(parentViewController.currentAlert, "The geofencing alert controller could not be found.") - guard alert.actions.count == 2 else { - XCTFail("Telemetry alert should have 2 actions") - return - } + XCTAssertEqual(GeofencingStrings.geofencingEnabledOffMessage, geofencingAlert.actions[0].title) + XCTAssertEqual(GeofencingStrings.geofencingEnabledOnMessage, geofencingAlert.actions[1].title) - let declineTitle = GeofencingStrings.geofencingDisabledOffMessage - XCTAssertEqual(alert.actions[0].title, declineTitle) - - let participateTitle = GeofencingStrings.geofencingDisabledOnMessage - XCTAssertEqual(alert.actions[1].title, participateTitle) + geofencingAlert.tapButton(atIndex: 1) + XCTAssertTrue(attributionMenu.isGeofencingEnabled) } - func testShowTelemetryDialogMetricsEnabled() throws { - let viewController = UIViewController() - let window = UIWindow() - window.rootViewController = viewController - window.makeKeyAndVisible() - attributionDialogManager.isMetricsEnabled = true + func testGeofencingOptOut() throws { + attributionMenu.isGeofencingEnabled = true + isGeofenceActive = true + attributionMenu.filter = { $0.category == .geofencing } - attributionDialogManager.showTelemetryAlertController(from: viewController) + attributionDialogManager.didTap(InfoButtonOrnament()) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) - let telemetryTitle = TelemetryStrings.telemetryTitle - XCTAssertEqual(alert.title, telemetryTitle) + var infoAlert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") + XCTAssertNotNil(infoAlert) + infoAlert.tapButton(atIndex: 0) - let message = TelemetryStrings.telemetryEnabledMessage - XCTAssertEqual(alert.message, message) + var geofencingAlert = try XCTUnwrap(parentViewController.currentAlert, "The geofencing alert controller could not be found.") - guard alert.actions.count == 3 else { - XCTFail("Telemetry alert should have 3 actions") + XCTAssertEqual(geofencingAlert.title, GeofencingStrings.geofencingTitle) + XCTAssertEqual(geofencingAlert.message, GeofencingStrings.geofencingMessage) + + guard geofencingAlert.actions.count == 2 else { + XCTFail("Geofencing alert should have 2 actions") return } - let moreTitle = TelemetryStrings.telemetryMore - XCTAssertEqual(alert.actions[0].title, moreTitle) - - let declineTitle = TelemetryStrings.telemetryEnabledOffMessage - XCTAssertEqual(alert.actions[1].title, declineTitle) - - let participateTitle = TelemetryStrings.telemetryEnabledOnMessage - XCTAssertEqual(alert.actions[2].title, participateTitle) - } - - func testShowTelemetryDialogMetricsDisabled() throws { - let viewController = UIViewController() - let bundle = Bundle.mapboxMaps - let window = UIWindow() - window.rootViewController = viewController - window.makeKeyAndVisible() - attributionDialogManager.isMetricsEnabled = false + XCTAssertEqual(GeofencingStrings.geofencingEnabledOffMessage, geofencingAlert.actions[0].title) + XCTAssertEqual(GeofencingStrings.geofencingEnabledOnMessage, geofencingAlert.actions[1].title) + XCTAssertTrue(attributionMenu.isGeofencingEnabled) - attributionDialogManager.showTelemetryAlertController(from: viewController) + geofencingAlert.tapButton(atIndex: 0) + XCTAssertFalse(attributionMenu.isGeofencingEnabled) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) - let telemetryTitle = TelemetryStrings.telemetryTitle - XCTAssertEqual(alert.title, telemetryTitle) - - let message = TelemetryStrings.telemetryDisabledMessage - XCTAssertEqual(alert.message, message) - - guard alert.actions.count == 3 else { - XCTFail("Telemetry alert should have 3 actions") - return - } + attributionDialogManager.didTap(InfoButtonOrnament()) - let moreTitle = TelemetryStrings.telemetryMore - XCTAssertEqual(alert.actions[0].title, moreTitle) + infoAlert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") + infoAlert.tapButton(atIndex: 0) + geofencingAlert = try XCTUnwrap(parentViewController.currentAlert, "The geofencing alert controller could not be found.") - let declineTitle = TelemetryStrings.telemetryDisabledOffMessage - XCTAssertEqual(alert.actions[1].title, declineTitle) + XCTAssertEqual(GeofencingStrings.geofencingDisabledOffMessage, geofencingAlert.actions[0].title) + XCTAssertEqual(GeofencingStrings.geofencingDisabledOnMessage, geofencingAlert.actions[1].title) - let participateTitle = TelemetryStrings.telemetryDisabledOnMessage - XCTAssertEqual(alert.actions[2].title, participateTitle) + geofencingAlert.tapButton(atIndex: 0) + XCTAssertFalse(attributionMenu.isGeofencingEnabled) } func testShowAttributionDialogNoAttributions() throws { - let viewController = UIViewController() - let bundle = Bundle.mapboxMaps - let window = UIWindow() - window.rootViewController = viewController - window.makeKeyAndVisible() - mockDelegate.viewControllerForPresentingStub.defaultReturnValue = viewController - attributionDialogManager.didTap(InfoButtonOrnament()) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) + let alert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") let alertTitle = NSLocalizedString("SDK_NAME", tableName: nil, value: "Powered by Mapbox Maps", @@ -181,26 +149,21 @@ class AttributionDialogTests: XCTestCase { let cancelTitle = NSLocalizedString("CANCEL", tableName: Ornaments.localizableTableName, - bundle: bundle, + bundle: .mapboxMaps, value: "Cancel", comment: "") XCTAssertEqual(alert.actions[2].title, cancelTitle) } func testShowAttributionDialogSingleNonActionableAttribution() throws { - let viewController = UIViewController() - let window = UIWindow() +// attributionMenu.filter = { $0.id == .copyright } let attribution = Attribution(title: String.randomASCII(withLength: 10), url: nil) - window.rootViewController = viewController - window.makeKeyAndVisible() - mockDataSource.loadAttributionsStub.defaultSideEffect = { invocation in - invocation.parameters([attribution]) - } - mockDelegate.viewControllerForPresentingStub.defaultReturnValue = viewController + + mockDataSource.attributions = [attribution] attributionDialogManager.didTap(InfoButtonOrnament()) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) + let alert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") let alertTitle = NSLocalizedString("SDK_NAME", tableName: nil, value: "Powered by Mapbox Maps", @@ -218,21 +181,14 @@ class AttributionDialogTests: XCTestCase { } func testShowAttributionDialogTwoAttributions() throws { - let viewController = UIViewController() - let window = UIWindow() let attribution0 = Attribution(title: String.randomASCII(withLength: 10), url: nil) let attribution1 = Attribution(title: String.randomASCII(withLength: 10), url: URL(string: "http://example.com")!) - window.rootViewController = viewController - window.makeKeyAndVisible() - mockDataSource.loadAttributionsStub.defaultSideEffect = { invocation in - invocation.parameters([attribution0, attribution1]) - } - mockDelegate.viewControllerForPresentingStub.defaultReturnValue = viewController + mockDataSource.attributions = [attribution0, attribution1] attributionDialogManager.didTap(InfoButtonOrnament()) - let alert = try XCTUnwrap(viewController.presentedViewController as? UIAlertController) + let alert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") let alertTitle = NSLocalizedString("SDK_NAME", tableName: nil, value: "Powered by Mapbox Maps", @@ -250,4 +206,63 @@ class AttributionDialogTests: XCTestCase { XCTAssertEqual(alert.actions[0].title, attribution0.title) XCTAssertEqual(alert.actions[1].title, attribution1.title) } + + func testAttributionFilteringID() throws { + let attribution0 = Attribution(title: String.randomASCII(withLength: 10), url: nil) + let attribution1 = Attribution(title: String.randomASCII(withLength: 10), url: URL(string: "http://example.com")!) + + mockDataSource.attributions = [attribution0, attribution1] + + attributionMenu.filter = { $0.id == .copyright || $0.id == .privacyPolicy } + + attributionDialogManager.didTap(.init()) + + let alert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") + + // Single, non-actionable attributions should be displayed as alert's actions along the telemetry and cancel actions + guard alert.actions.count == 3 else { + XCTFail("Telemetry alert should have 3 actions") + return + } + + let privacyPolicyTitle = NSLocalizedString("ATTRIBUTION_PRIVACY_POLICY", + tableName: Ornaments.localizableTableName, + bundle: .mapboxMaps, + value: "Mapbox Privacy Policy", + comment: "Privacy policy action in attribution sheet") + + XCTAssertEqual(alert.actions[0].title, attribution0.title) + XCTAssertEqual(alert.actions[1].title, attribution1.title) + XCTAssertEqual(alert.actions[2].title, privacyPolicyTitle) + } + + func testAttributionFilteringCategory() throws { + isGeofenceActive = true + let attribution0 = Attribution(title: String.randomASCII(withLength: 10), url: nil) + let attribution1 = Attribution(title: String.randomASCII(withLength: 10), url: URL(string: "http://example.com")!) + + mockDataSource.attributions = [attribution0, attribution1] + + attributionMenu.filter = { $0.category == .telemetry || $0.category == .geofencing } + + attributionDialogManager.didTap(.init()) + + let alert = try XCTUnwrap(parentViewController.currentAlert, "The info alert controller could not be found.") + + // Single, non-actionable attributions should be displayed as alert's actions along the telemetry and cancel actions + guard alert.actions.count == 2 else { + XCTFail("Telemetry alert should have 2 actions") + return + } + + XCTAssertEqual(alert.actions[0].title, TelemetryStrings.telemetryName) + XCTAssertEqual(alert.actions[1].title, GeofencingStrings.geofencingName) + } + +} + +extension AttributionDialogTests: AttributionDialogManagerDelegate { + func viewControllerForPresenting(_ attributionDialogManager: AttributionDialogManager) -> UIViewController? { + return parentViewController + } } diff --git a/Tests/MapboxMapsTests/Style/AttributionTests.swift b/Tests/MapboxMapsTests/Style/AttributionTests.swift index 493232816065..92eaacf5e310 100644 --- a/Tests/MapboxMapsTests/Style/AttributionTests.swift +++ b/Tests/MapboxMapsTests/Style/AttributionTests.swift @@ -179,7 +179,7 @@ class AttributionTests: XCTestCase { let expectedURL = try XCTUnwrap(URL(string: "https://apps.mapbox.com/feedback/?referrer=\(Bundle.main.bundleIdentifier!)&owner=mapbox&id=standard&access_token=test-token&map_sdk_version=\(metadata.version)#/2.00000/1.00000/3.00/4.0/5")) let mapView = MapView(frame: .zero, mapInitOptions: mapInitOptions) - let url = mapView.mapboxFeedbackURL(accessToken: "test-token") + let url = mapView.mapboxMap.mapboxFeedbackURL(accessToken: "test-token") XCTAssertEqual(expectedURL, url) } diff --git a/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDataSource.swift b/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDataSource.swift index 0c9d88deb6e9..9f265e8ebbc1 100644 --- a/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDataSource.swift +++ b/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDataSource.swift @@ -3,9 +3,9 @@ import XCTest import Foundation final class MockAttributionDataSource: AttributionDataSource { + var attributions: [MapboxMaps.Attribution] = [] let loadAttributionsStub = Stub<(([Attribution]) -> Void), Void>() func loadAttributions(completion: @escaping ([MapboxMaps.Attribution]) -> Void) { - loadAttributionsStub.call(with: completion) - completion([]) + completion(attributions) } } diff --git a/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDialogManagerDelegate.swift b/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDialogManagerDelegate.swift index 2dd8a898dda5..0d72cdb8c6df 100644 --- a/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDialogManagerDelegate.swift +++ b/Tests/MapboxMapsTests/Style/Mocks/MockAttributionDialogManagerDelegate.swift @@ -8,16 +8,4 @@ final class MockAttributionDialogManagerDelegate: AttributionDialogManagerDelega func viewControllerForPresenting(_ attributionDialogManager: AttributionDialogManager) -> UIViewController? { viewControllerForPresentingStub.call(with: attributionDialogManager) } - - struct TriggerActionForParameters { - let attributionDialogManager: AttributionDialogManager - let attribution: Attribution - } - - let attributionDialogManagerStub = Stub() - func attributionDialogManager(_ attributionDialogManager: AttributionDialogManager, didTriggerActionFor attribution: Attribution) { - attributionDialogManagerStub.call(with: - TriggerActionForParameters(attributionDialogManager: attributionDialogManager, - attribution: attribution)) - } } diff --git a/Tests/MapboxMapsTests/SwiftUI/Mocks/MockMapView.swift b/Tests/MapboxMapsTests/SwiftUI/Mocks/MockMapView.swift index a4120b7e5712..337f0139352d 100644 --- a/Tests/MapboxMapsTests/SwiftUI/Mocks/MockMapView.swift +++ b/Tests/MapboxMapsTests/SwiftUI/Mocks/MockMapView.swift @@ -1,6 +1,6 @@ import UIKit import SwiftUI -@_spi(Experimental) @testable import MapboxMaps +@_spi(Experimental) @_spi(Restricted) @testable import MapboxMaps @available(iOS 13.0, *) struct MockMapView { @@ -9,7 +9,7 @@ struct MockMapView { var gestures = MockGestureManager() var viewportManager = MockViewportManager() var ornaments = MockOrnamentsManager() - + var attributionMenu = AttributionMenu(urlOpener: MockAttributionURLOpener(), feedbackURLRef: Ref { nil }) var makeViewportTransitionStub = Stub(defaultReturnValue: MockViewportTransition()) struct MakeViewportParameters { var viewport: Viewport @@ -29,6 +29,7 @@ struct MockMapView { isOpaque: false, presentationTransactionMode: .automatic, frameRate: Map.FrameRate(), + attributionMenu: attributionMenu, makeViewportTransition: makeViewportTransitionStub.call(with:), makeViewportState: { [makeViewportStateStub] viewport, layoutDirection in makeViewportStateStub.call(with: MakeViewportParameters(viewport: viewport, layoutDirection: layoutDirection))