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 */,