diff --git a/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift b/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift index e69704325ae..014a083925a 100644 --- a/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift +++ b/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift @@ -116,6 +116,10 @@ final class ServiceLocator { /// private static var _startupWaitingTimeTracker: AppStartupWaitingTimeTracker = AppStartupWaitingTimeTracker() + /// Age range verification (Declared Age Range API wrapper) + /// + private static var _ageRangeVerificationService: AgeRangeVerificationServiceProtocol = AgeRangeVerificationService() + // MARK: - Getters /// Provides the access point to the analytics. @@ -308,6 +312,10 @@ final class ServiceLocator { _startupWaitingTimeTracker } + static var ageRangeVerificationService: AgeRangeVerificationServiceProtocol { + _ageRangeVerificationService + } + /// Provides access point to the `POSCatalogSyncCoordinator`. /// Returns nil if feature flag is disabled or user is not authenticated. /// @@ -446,6 +454,14 @@ extension ServiceLocator { _receiptPrinter = mock } + static func setAgeRangeVerificationService(_ mock: AgeRangeVerificationServiceProtocol) { + guard isRunningTests() else { + return + } + + _ageRangeVerificationService = mock + } + static func setConnectivityObserver(_ mock: ConnectivityObserver) { guard isRunningTests() else { return diff --git a/WooCommerce/Classes/Tools/AgeVerification/AgeRangeVerificationService.swift b/WooCommerce/Classes/Tools/AgeVerification/AgeRangeVerificationService.swift new file mode 100644 index 00000000000..bd173ce2a5d --- /dev/null +++ b/WooCommerce/Classes/Tools/AgeVerification/AgeRangeVerificationService.swift @@ -0,0 +1,134 @@ +import Foundation +import CocoaLumberjack +import UIKit +#if canImport(DeclaredAgeRange) +import DeclaredAgeRange +#endif + +enum AgeRangeVerificationResult { + /// The feature is supported and declared user age is within the required range + case eligible + + /// The feature is supported but declared user age is outside of the required range + case ineligible + + /// User or parent refused from age sharing + case declinedSharing + + /// Feature is unavailable in the current environment. I.e. iOS version is below `26.0`. + case featureUnavailable + + /// Failed to obtain a view controller suitable for system Age Range dialogue. I.e. the provided view controller is outside of UI stack or not presented. + case invalidUIState + + /// `DeclaredAgeRange` SDK flow produced an error. + case sdkError(Error) + + case unknown +} + +protocol AgeRangeVerificationServiceProtocol { + /// Triggers the age range verification flow. + /// - Parameters: + /// - viewController: Anchor for the system sheet/prompt. + /// - minimumAge: Primary age gate (required). + /// - additionalThresholds: Optional additional gates (up to two values). + /// - completion: Called with the interpreted outcome. + func verifyAgeRange( + in viewController: UIViewController, + minimumAge: Int, + completion: @escaping (AgeRangeVerificationResult) -> Void + ) +} + +#if canImport(DeclaredAgeRange) +final class AgeRangeVerificationService: AgeRangeVerificationServiceProtocol { + private enum Constants { + static let minimumAge = 13 + } + + /// Requests the user's declared age range using Apple's DeclaredAgeRange API (iOS 26+). + /// The system will present its own consent UI if needed. + func verifyAgeRange( + in viewController: UIViewController, + minimumAge: Int = Constants.minimumAge, + completion: @escaping (AgeRangeVerificationResult) -> Void + ) { + guard #available(iOS 26.0, *) else { + completion(.featureUnavailable) + return + } + + Task { @MainActor in + // Use the topmost visible controller as the anchor to ensure UI can be presented. + let anchor = viewController.topmostPresentedViewController + guard anchor.view.window != nil else { + DDLogWarn("Declared Age Range API: Anchor viewController is not in window; skipping request.") + completion(.invalidUIState) + return + } + + do { + let response = try await requestAgeRangeResponse( + minimumAge: minimumAge, + viewController: anchor + ) + let result = mapResponseToResult( + response, + minimumAge: minimumAge + ) + DDLogInfo("Declared Age Range API: Response mapped to \(result)") + completion(result) + } catch { + if let ageError = error as? AgeRangeService.Error, ageError == .notAvailable { + DDLogInfo("Declared Age Range API: Not available (simulator or account not eligible); skipping further prompts.") + } else { + DDLogError("Declared Age Range API: Failed to retrieve age range. Error: \(error)") + } + completion(.sdkError(error)) + } + } + } +} + +@available(iOS 26.0, *) +private extension AgeRangeVerificationService { + func requestAgeRangeResponse( + minimumAge: Int, + viewController: UIViewController + ) async throws -> AgeRangeService.Response { + return try await AgeRangeService.shared.requestAgeRange( + ageGates: minimumAge, + in: viewController + ) + } + + func mapResponseToResult( + _ response: AgeRangeService.Response, + minimumAge: Int + ) -> AgeRangeVerificationResult { + switch response { + case let .sharing(range): + if let lowerBound = range.lowerBound, lowerBound >= minimumAge { + return .eligible + } + return .ineligible + case .declinedSharing: + return .declinedSharing + @unknown default: + return .unknown + } + } +} +#else +/// Fallback implementation when the DeclaredAgeRange SDK is unavailable (e.g., older Xcode/SDK). +final class AgeRangeVerificationService: AgeRangeVerificationServiceProtocol { + func verifyAgeRange( + in viewController: UIViewController, + minimumAge: Int, + completion: @escaping (AgeRangeVerificationResult) -> Void + ) { + completion(.featureUnavailable) + } +} +#endif diff --git a/WooCommerce/Classes/ViewRelated/AgeGate/AgeRangeVerificationCoordinator.swift b/WooCommerce/Classes/ViewRelated/AgeGate/AgeRangeVerificationCoordinator.swift new file mode 100644 index 00000000000..b461c5b119f --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/AgeGate/AgeRangeVerificationCoordinator.swift @@ -0,0 +1,49 @@ +import UIKit + +protocol AgeRangeVerificationCoordinatorProtocol { + func triggerAgeVerificationIfNeeded( + hostingWindow: UIWindow, + onResult: @escaping (Bool, AgeRangeVerificationResult) -> Void + ) +} + +final class AgeRangeVerificationCoordinator: AgeRangeVerificationCoordinatorProtocol { + /// Triggers the age range verification flow. + /// Handles "blocking UI" presenting in case of ineligible age and performs a logout. + /// - Parameters: + /// - hostingWindow: The window that handles the dialogue UI. Basically the main app window works well. + /// - onResult: Called on when a result is obtained. Passes if the age is eligible + verification result value. + func triggerAgeVerificationIfNeeded( + hostingWindow: UIWindow, + onResult: @escaping (Bool, AgeRangeVerificationResult) -> Void + ) { + guard let anchor = hostingWindow.topmostPresentedViewController else { + DDLogWarn("Failed to obtain view controller to present `Declared Age Range` SDK dialogue.") + // Allow flow to continue if we can't present the dialogue. + onResult(true, .invalidUIState) + return + } + + ServiceLocator.ageRangeVerificationService.verifyAgeRange( + in: anchor, + minimumAge: 18 + ) { result in + let isEligible: Bool + switch result { + case .eligible: + isEligible = true + case .ineligible: + isEligible = false + case .declinedSharing, + .featureUnavailable, + .invalidUIState, + .sdkError, + .unknown: + // Non-deterministic/unavailable results are treated as allowed. + isEligible = true + } + + onResult(isEligible, result) + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/AppCoordinator.swift b/WooCommerce/Classes/ViewRelated/AppCoordinator.swift index 61985aca72b..9d762a8454d 100644 --- a/WooCommerce/Classes/ViewRelated/AppCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/AppCoordinator.swift @@ -32,6 +32,9 @@ final class AppCoordinator { /// private lazy var appleIDCredentialChecker = AppleIDCredentialChecker() + /// Handles the age range verification process and corresponding app/UI state behaviour. + private let ageRangeVerificationCoordinator: AgeRangeVerificationCoordinatorProtocol = AgeRangeVerificationCoordinator() + init(window: UIWindow, stores: StoresManager = ServiceLocator.stores, storageManager: StorageManagerType = ServiceLocator.storageManager, @@ -90,6 +93,7 @@ final class AppCoordinator { case (true, false): self.validateRoleEligibility { self.displayLoggedInUI() + self.triggerAgeVerification() self.synchronizeAndShowWhatsNew() } } @@ -432,12 +436,73 @@ private extension AppCoordinator { } } +private extension AppCoordinator { + func triggerAgeVerification(onAllowed: @escaping () -> Void = { }) { + ageRangeVerificationCoordinator.triggerAgeVerificationIfNeeded( + hostingWindow: window + ) { [weak self] isEligible, _ in + guard let self else { return } + if isEligible { + onAllowed() + } else { + self.forceLogoutAndShowAgeAlert() + } + + //TODO: consider adding analytics event with the result + } + } + + /// Centralized flow to log out and present the login/onboarding UI. + func forceLogoutAndReturnToLogin() { + stores.deauthenticate() + displayAuthenticatorWithOnboardingIfNeeded() + } + + func forceLogoutAndShowAgeAlert() { + forceLogoutAndReturnToLogin() + + DispatchQueue.main.async { [weak self] in + guard + let self, + let presenter = self.window.topmostPresentedViewController + else { return } + let alert = UIAlertController( + title: Localization.AgeVerificationAlert.title, + message: Localization.AgeVerificationAlert.message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Got it", style: .default, handler: nil)) + presenter.present(alert, animated: true) + } + } +} + private extension AppCoordinator { enum Constants { static let animationDuration = TimeInterval(0.3) } enum Localization { + enum AgeVerificationAlert { + static let title = NSLocalizedString( + "appCoordinator.ineligibleAgeRangeAlert.title", + value: "Access Not Allowed", + comment: "Alert title displayed when user identified as underage and taken to force logout." + ) + + static let message = NSLocalizedString( + "appCoordinator.ineligibleAgeRangeAlert.message", + value: "Based on your account settings, you're not eligible to use this app.", + comment: "Alert message displayed when user identified as underage and taken to force logout." + ) + + static let confirmationButton = NSLocalizedString( + "appCoordinator.ineligibleAgeRangeAlert.confirmationButton", + value: "Got it", + comment: "Alert confirmation button displayed when user identified as underage and taken to force logout." + ) + } + enum StoreReadyAlert { static let title = NSLocalizedString("appCoordinator.storeReadyAlert.title", value: "Your new store is ready.", diff --git a/WooCommerce/Resources/Woo-Debug.entitlements b/WooCommerce/Resources/Woo-Debug.entitlements index 611819741f2..542181f9b3d 100644 --- a/WooCommerce/Resources/Woo-Debug.entitlements +++ b/WooCommerce/Resources/Woo-Debug.entitlements @@ -52,5 +52,7 @@ com.apple.developer.proximity-reader.payment.acceptance + com.apple.developer.declared-age-range + diff --git a/WooCommerce/Resources/Woo-Release.entitlements b/WooCommerce/Resources/Woo-Release.entitlements index c39cb8eb316..f9d6b3f8439 100644 --- a/WooCommerce/Resources/Woo-Release.entitlements +++ b/WooCommerce/Resources/Woo-Release.entitlements @@ -52,5 +52,7 @@ com.apple.developer.proximity-reader.payment.acceptance + com.apple.developer.declared-age-range + diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 00fad2c6707..f6b8c0fbc9d 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -982,6 +982,7 @@ 26FE09E124DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26FE09E024DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift */; }; 26FFC50C2BED7C5A0067B3A4 /* WatchDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B249702BEC801400730730 /* WatchDependencies.swift */; }; 26FFC50D2BED7C5B0067B3A4 /* WatchDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B249702BEC801400730730 /* WatchDependencies.swift */; }; + 2D047C9E2EE6DA590014B255 /* AgeRangeVerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D047C9C2EE6DA590014B255 /* AgeRangeVerificationService.swift */; }; 2D052FB42E9408AF004111FD /* CustomerDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */; }; 2D052FB52E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */; }; 2D05337E2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05337D2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift */; }; @@ -999,6 +1000,7 @@ 2D09E0D12E61BC7F005C26F3 /* ApplicationPasswordsExperimentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D09E0D02E61BC7D005C26F3 /* ApplicationPasswordsExperimentState.swift */; }; 2D09E0D52E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D09E0D42E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift */; }; 2D200F072ED7245000DD6EBF /* BookingDateTimeFilterViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D200F062ED7245000DD6EBF /* BookingDateTimeFilterViewTests.swift */; }; + 2D205B882EF2B99500E1F7A2 /* AgeRangeVerificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D205B872EF2B99400E1F7A2 /* AgeRangeVerificationCoordinator.swift */; }; 2D7A3E232E7891DB00C46401 /* CIABEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7A3E222E7891D200C46401 /* CIABEligibilityCheckerTests.swift */; }; 2D880B492DFB2F3F00A6FB2C /* OptionalBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D880B482DFB2F3D00A6FB2C /* OptionalBinding.swift */; }; 2D88C1112DF883C300A6FB2C /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D88C1102DF883BD00A6FB2C /* AttributedString+Helpers.swift */; }; @@ -3874,6 +3876,7 @@ 26FE09E024DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyCoordinatorControllerTests.swift; sourceTree = ""; }; 26FFD32628C6A0A4002E5E5E /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 26FFD32928C6A0F4002E5E5E /* UIImage+Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Widgets.swift"; sourceTree = ""; }; + 2D047C9C2EE6DA590014B255 /* AgeRangeVerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeRangeVerificationService.swift; sourceTree = ""; }; 2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsView+RowTextStyle.swift"; sourceTree = ""; }; 2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerDetailsView.swift; sourceTree = ""; }; 2D05337D2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+PriceFormatting.swift"; sourceTree = ""; }; @@ -3891,6 +3894,7 @@ 2D09E0D02E61BC7D005C26F3 /* ApplicationPasswordsExperimentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordsExperimentState.swift; sourceTree = ""; }; 2D09E0D42E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordsExperimentStateTests.swift; sourceTree = ""; }; 2D200F062ED7245000DD6EBF /* BookingDateTimeFilterViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingDateTimeFilterViewTests.swift; sourceTree = ""; }; + 2D205B872EF2B99400E1F7A2 /* AgeRangeVerificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeRangeVerificationCoordinator.swift; sourceTree = ""; }; 2D7A3E222E7891D200C46401 /* CIABEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIABEligibilityCheckerTests.swift; sourceTree = ""; }; 2D880B482DFB2F3D00A6FB2C /* OptionalBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalBinding.swift; sourceTree = ""; }; 2D88C1102DF883BD00A6FB2C /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; }; @@ -7913,6 +7917,22 @@ path = Images; sourceTree = ""; }; + 2D047C9D2EE6DA590014B255 /* AgeVerification */ = { + isa = PBXGroup; + children = ( + 2D047C9C2EE6DA590014B255 /* AgeRangeVerificationService.swift */, + ); + path = AgeVerification; + sourceTree = ""; + }; + 2D0490532EEAF5E90014B255 /* AgeGate */ = { + isa = PBXGroup; + children = ( + 2D205B872EF2B99400E1F7A2 /* AgeRangeVerificationCoordinator.swift */, + ); + path = AgeGate; + sourceTree = ""; + }; 2D7A3E212E7891C400C46401 /* CIAB */ = { isa = PBXGroup; children = ( @@ -9591,6 +9611,7 @@ B55D4C2220B716CE00D7A50F /* Tools */ = { isa = PBXGroup; children = ( + 2D047C9D2EE6DA590014B255 /* AgeVerification */, 01BB6C082D09E9200094D55B /* Location */, 26BCA03E2C35E965000BE96C /* BackgroundTasks */, EEADF61C281A3DB7001B40F1 /* ShippingValueLocalizer */, @@ -9707,6 +9728,7 @@ B56DB3EF2049C06D00D4AA8E /* ViewRelated */ = { isa = PBXGroup; children = ( + 2D0490532EEAF5E90014B255 /* AgeGate */, 2DAC2C952E82A15C008521AF /* Bookings */, 68B3BA242D91473D0000B2F2 /* AI Settings */, B626C7192876599B0083820C /* Custom Fields */, @@ -14027,6 +14049,7 @@ 03EF24FC28BF996F006A033E /* InPersonPaymentsCashOnDeliveryPaymentGatewayHelpers.swift in Sources */, 57896D6625362B0C000E8C4D /* TitleAndEditableValueTableViewCellViewModel.swift in Sources */, 0205021E27C8B6C600FB1C6B /* InboxEligibilityUseCase.swift in Sources */, + 2D205B882EF2B99500E1F7A2 /* AgeRangeVerificationCoordinator.swift in Sources */, 26E7EE6E29300E8100793045 /* AnalyticsTopPerformersCard.swift in Sources */, 03E471DA29424E82001A58AD /* CardPresentModalTapToPaySuccess.swift in Sources */, 26E1BECE251CD9F80096D0A1 /* RefundItemViewModel.swift in Sources */, @@ -14652,6 +14675,7 @@ DE3650682B5128CE001569A7 /* BlazeTargetLocationPickerViewModel.swift in Sources */, B958640C2A66847B002C4C6E /* CouponListView.swift in Sources */, DEDA8DBE2B19952B0076BF0F /* ThemeSettingViewModel.swift in Sources */, + 2D047C9E2EE6DA590014B255 /* AgeRangeVerificationService.swift in Sources */, CEFA16F12B74F64D00512782 /* AnalyticsHubCustomizeView.swift in Sources */, 68C31B712A8617C500AE5C5A /* NewNoteViewModel.swift in Sources */, D41C9F2E26D9A0E900993558 /* WhatsNewViewModel.swift in Sources */,