Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions WooCommerce/Classes/ServiceLocator/ServiceLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import DeclaredAgeRange
import CocoaLumberjack
import UIKit

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
)
}

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 {
// TODO: Check user region
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
}
}
}
62 changes: 62 additions & 0 deletions WooCommerce/Classes/ViewRelated/AgeGate/AgeGateBlockedView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import SwiftUI

public enum AgeGateBlockReason {
case tooYoung
case consentRevoked

var title: String {
switch self {
case .tooYoung: return "Access Not Allowed"
case .consentRevoked: return "Access Revoked"
}
}

var message: String {
switch self {
case .tooYoung:
return "Based on your account settings, you're not eligible to use this app."
case .consentRevoked:
return "Permission to use this app has been revoked by your Apple family settings."
}
}
}

public struct AgeGateBlockedView: View {
let reason: AgeGateBlockReason
let onPrimaryAction: () -> Void

public init(reason: AgeGateBlockReason, onPrimaryAction: @escaping () -> Void) {
self.reason = reason
self.onPrimaryAction = onPrimaryAction
}

public var body: some View {
VStack(spacing: 24) {
VStack(spacing: 8) {
Text(reason.title)
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text(reason.message)
.font(.body)
.multilineTextAlignment(.center)
}
Button("Return to Login", action: onPrimaryAction)
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
}

public final class AgeGateBlockedHostingController: UIHostingController<AgeGateBlockedView> {
public init(reason: AgeGateBlockReason, onPrimaryAction: @escaping () -> Void) {
super.init(rootView: AgeGateBlockedView(reason: reason, onPrimaryAction: onPrimaryAction))
modalPresentationStyle = .fullScreen
}

@available(*, unavailable)
public required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
72 changes: 72 additions & 0 deletions WooCommerce/Classes/ViewRelated/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,6 +93,14 @@ final class AppCoordinator {
case (true, false):
self.validateRoleEligibility {
self.displayLoggedInUI()
self.ageRangeVerificationCoordinator.triggerAgeVerificationIfNeeded(
hostingWindow: self.window
) { [weak self] in
guard let self else { return }
// Log out and return to login flow.
stores.deauthenticate()
displayAuthenticatorWithOnboardingIfNeeded()
}
self.synchronizeAndShowWhatsNew()
}
}
Expand Down Expand Up @@ -432,6 +443,67 @@ private extension AppCoordinator {
}
}

protocol AgeRangeVerificationCoordinatorProtocol {
func triggerAgeVerificationIfNeeded(
hostingWindow: UIWindow,
onBlockingFallback: @escaping () -> 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.
/// - onBlockingFallback: Called when a confirmation CTA is tapped inside "blocking UI". Designed to trigger a logout on tap.
func triggerAgeVerificationIfNeeded(
hostingWindow: UIWindow,
onBlockingFallback: @escaping () -> Void
) {
guard let anchor = hostingWindow.topmostPresentedViewController else {
DDLogWarn("Failed to obtain view controller to present `Declared Age Range` SDK dialogue.")
return
}

ServiceLocator.ageRangeVerificationService.verifyAgeRange(
in: anchor,
minimumAge: 18
) { [weak self] result in
guard let self else { return }
switch result {
case .eligible:
/// The specified user age range satisfies requirements
break
case .ineligible:
/// The specified user age range is below requirement
presentAgeGateBlock(
reason: .tooYoung,
hostingWindow: hostingWindow,
onCTATap: onBlockingFallback
)
case .declinedSharing,
.featureUnavailable,
.invalidUIState,
.sdkError,
.unknown:
/// We don't block the app usage if the age verifying is not possible
break
}
}
}

func presentAgeGateBlock(
reason: AgeGateBlockReason,
hostingWindow: UIWindow,
onCTATap: @escaping () -> Void
) {
hostingWindow.rootViewController = AgeGateBlockedHostingController(
reason: reason,
onPrimaryAction: onCTATap
)
}
}

private extension AppCoordinator {
enum Constants {
static let animationDuration = TimeInterval(0.3)
Expand Down
2 changes: 2 additions & 0 deletions WooCommerce/Resources/Woo-Alpha.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,7 @@
<array>
<string>$(AppIdentifierPrefix)com.automattic.woocommerce</string>
</array>
<key>com.apple.developer.declared-age-range</key>
<true/>
</dict>
</plist>
2 changes: 2 additions & 0 deletions WooCommerce/Resources/Woo-Debug.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,7 @@
</array>
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
<true/>
<key>com.apple.developer.declared-age-range</key>
<true/>
</dict>
</plist>
2 changes: 2 additions & 0 deletions WooCommerce/Resources/Woo-Release.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,7 @@
</array>
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
<true/>
<key>com.apple.developer.declared-age-range</key>
<true/>
</dict>
</plist>
Loading